rebuild discovery pool flow to allow multiprocessing of itunes and spotfiy. each getting their own pool.

This commit is contained in:
Broque Thomas 2026-01-23 14:25:26 -08:00
parent fecd371b5e
commit 1560726bbc
5 changed files with 856 additions and 455 deletions

View file

@ -100,6 +100,39 @@ class PersonalizedPlaylistsService:
self.database = database self.database = database
self.spotify_client = spotify_client 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 @staticmethod
def get_parent_genre(spotify_genre: str) -> str: def get_parent_genre(spotify_genre: str) -> str:
""" """
@ -166,25 +199,30 @@ class PersonalizedPlaylistsService:
logger.error(f"Error getting forgotten favorites: {e}") logger.error(f"Error getting forgotten favorites: {e}")
return [] 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. Get tracks from a specific decade from discovery pool with diversity filtering.
Args: Args:
decade: Decade year (e.g., 2020 for 2020s, 2010 for 2010s) decade: Decade year (e.g., 2020 for 2020s, 2010 for 2010s)
limit: Maximum tracks to return limit: Maximum tracks to return
source: Optional source filter ('spotify' or 'itunes'), auto-detects if not provided
""" """
try: try:
start_year = decade start_year = decade
end_year = decade + 9 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: with self.database._get_connection() as conn:
cursor = conn.cursor() 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(""" cursor.execute("""
SELECT SELECT
spotify_track_id, spotify_track_id,
itunes_track_id,
track_name, track_name,
artist_name, artist_name,
album_name, album_name,
@ -192,26 +230,20 @@ class PersonalizedPlaylistsService:
duration_ms, duration_ms,
popularity, popularity,
release_date, release_date,
track_data_json track_data_json,
source
FROM discovery_pool FROM discovery_pool
WHERE release_date IS NOT NULL WHERE release_date IS NOT NULL
AND CAST(SUBSTR(release_date, 1, 4) AS INTEGER) BETWEEN ? AND ? AND CAST(SUBSTR(release_date, 1, 4) AS INTEGER) BETWEEN ? AND ?
AND source = ?
ORDER BY RANDOM() ORDER BY RANDOM()
LIMIT ? LIMIT ?
""", (start_year, end_year, limit * 10)) """, (start_year, end_year, active_source, limit * 10))
rows = cursor.fetchall() rows = cursor.fetchall()
all_tracks = [] all_tracks = []
for row in rows: for row in rows:
track_dict = dict(row) all_tracks.append(self._build_track_dict(row, active_source))
# 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)
if not all_tracks: if not all_tracks:
logger.warning(f"No tracks found for {decade}s") 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}") logger.error(f"Error getting decade playlist for {decade}s: {e}")
return [] 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. Get list of consolidated parent genres with track counts from discovery pool.
Uses cached artist genres from database (populated during discovery scan). Uses cached artist genres from database (populated during discovery scan).
Consolidates specific Spotify genres into broader parent categories. Consolidates specific Spotify genres into broader parent categories.
""" """
try: try:
# Determine active source if not specified
active_source = source or self._get_active_source()
with self.database._get_connection() as conn: with self.database._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
# Get all tracks with genres from discovery pool # Get all tracks with genres from discovery pool, filtered by source
cursor.execute(""" cursor.execute("""
SELECT artist_genres SELECT artist_genres
FROM discovery_pool FROM discovery_pool
WHERE artist_genres IS NOT NULL WHERE artist_genres IS NOT NULL AND source = ?
""") """, (active_source,))
rows = cursor.fetchall() rows = cursor.fetchall()
if not rows: if not rows:
@ -327,20 +362,24 @@ class PersonalizedPlaylistsService:
logger.error(f"Error getting available genres: {e}") logger.error(f"Error getting available genres: {e}")
return [] 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. Get tracks from a specific genre with diversity filtering.
Uses cached artist genres from database (populated during discovery scan). Uses cached artist genres from database (populated during discovery scan).
Supports both parent genres (e.g., "Electronic/Dance") and specific genres (e.g., "house"). Supports both parent genres (e.g., "Electronic/Dance") and specific genres (e.g., "house").
""" """
try: try:
# Determine active source if not specified
active_source = source or self._get_active_source()
with self.database._get_connection() as conn: with self.database._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
# Get all tracks with genres from discovery pool # Get all tracks with genres from discovery pool, filtered by source
cursor.execute(""" cursor.execute("""
SELECT SELECT
spotify_track_id, spotify_track_id,
itunes_track_id,
track_name, track_name,
artist_name, artist_name,
album_name, album_name,
@ -348,10 +387,12 @@ class PersonalizedPlaylistsService:
duration_ms, duration_ms,
popularity, popularity,
artist_genres, artist_genres,
track_data_json track_data_json,
source
FROM discovery_pool FROM discovery_pool
WHERE artist_genres IS NOT NULL WHERE artist_genres IS NOT NULL
""") AND source = ?
""", (active_source,))
rows = cursor.fetchall() rows = cursor.fetchall()
# Determine if this is a parent genre or specific genre # Determine if this is a parent genre or specific genre
@ -372,7 +413,7 @@ class PersonalizedPlaylistsService:
for row in rows: for row in rows:
try: try:
artist_genres_json = row[7] # artist_genres column artist_genres_json = row['artist_genres']
if artist_genres_json: if artist_genres_json:
genres = json.loads(artist_genres_json) genres = json.loads(artist_genres_json)
@ -388,23 +429,7 @@ class PersonalizedPlaylistsService:
break break
if genre_match: if genre_match:
# Convert row to dict (exclude artist_genres from output) matching_tracks.append(self._build_track_dict(row, active_source))
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)
except Exception as e: except Exception as e:
logger.debug(f"Error parsing genres for track: {e}") logger.debug(f"Error parsing genres for track: {e}")
continue continue
@ -475,39 +500,34 @@ class PersonalizedPlaylistsService:
def get_popular_picks(self, limit: int = 50) -> List[Dict]: 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)""" """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: try:
with self.database._get_connection() as conn: with self.database._get_connection() as conn:
cursor = conn.cursor() 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(""" cursor.execute("""
SELECT SELECT
spotify_track_id, spotify_track_id,
itunes_track_id,
track_name, track_name,
artist_name, artist_name,
album_name, album_name,
album_cover_url, album_cover_url,
duration_ms, duration_ms,
popularity, popularity,
track_data_json track_data_json,
source
FROM discovery_pool FROM discovery_pool
WHERE popularity >= 60 WHERE popularity >= 60 AND source = ?
ORDER BY popularity DESC, RANDOM() ORDER BY popularity DESC, RANDOM()
LIMIT ? LIMIT ?
""", (limit * 3,)) # Get 3x more for diversity filtering """, (active_source, limit * 3))
rows = cursor.fetchall() rows = cursor.fetchall()
all_tracks = [] all_tracks = [self._build_track_dict(row, active_source) for row in rows]
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)
# Apply diversity constraint: max 2 tracks per album, max 3 per artist # Apply diversity constraint: max 2 tracks per album, max 3 per artist
tracks_by_album = {} tracks_by_album = {}
@ -531,7 +551,7 @@ class PersonalizedPlaylistsService:
if len(diverse_tracks) >= limit: if len(diverse_tracks) >= limit:
break 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] return diverse_tracks[:limit]
except Exception as e: except Exception as e:
@ -540,6 +560,9 @@ class PersonalizedPlaylistsService:
def get_hidden_gems(self, limit: int = 50) -> List[Dict]: def get_hidden_gems(self, limit: int = 50) -> List[Dict]:
"""Get low popularity (underground/indie) tracks from discovery pool""" """Get low popularity (underground/indie) tracks from discovery pool"""
# Determine active source
active_source = self._get_active_source()
try: try:
with self.database._get_connection() as conn: with self.database._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
@ -547,32 +570,23 @@ class PersonalizedPlaylistsService:
cursor.execute(""" cursor.execute("""
SELECT SELECT
spotify_track_id, spotify_track_id,
itunes_track_id,
track_name, track_name,
artist_name, artist_name,
album_name, album_name,
album_cover_url, album_cover_url,
duration_ms, duration_ms,
popularity, popularity,
track_data_json track_data_json,
source
FROM discovery_pool FROM discovery_pool
WHERE popularity < 40 WHERE popularity < 40 AND source = ?
ORDER BY RANDOM() ORDER BY RANDOM()
LIMIT ? LIMIT ?
""", (limit,)) """, (active_source, limit))
rows = cursor.fetchall() rows = cursor.fetchall()
tracks = [] return [self._build_track_dict(row, active_source) for row in rows]
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
except Exception as e: except Exception as e:
logger.error(f"Error getting hidden gems: {e}") logger.error(f"Error getting hidden gems: {e}")
@ -584,6 +598,9 @@ class PersonalizedPlaylistsService:
Different every time you call it! Different every time you call it!
""" """
# Determine active source
active_source = self._get_active_source()
try: try:
with self.database._get_connection() as conn: with self.database._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
@ -591,31 +608,23 @@ class PersonalizedPlaylistsService:
cursor.execute(""" cursor.execute("""
SELECT SELECT
spotify_track_id, spotify_track_id,
itunes_track_id,
track_name, track_name,
artist_name, artist_name,
album_name, album_name,
album_cover_url, album_cover_url,
duration_ms, duration_ms,
popularity, popularity,
track_data_json track_data_json,
source
FROM discovery_pool FROM discovery_pool
WHERE source = ?
ORDER BY RANDOM() ORDER BY RANDOM()
LIMIT ? LIMIT ?
""", (limit,)) """, (active_source, limit))
rows = cursor.fetchall() rows = cursor.fetchall()
tracks = [] return [self._build_track_dict(row, active_source) for row in rows]
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
except Exception as e: except Exception as e:
logger.error(f"Error getting discovery shuffle: {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]: def _get_discovery_tracks_by_category(self, category: str, limit: int) -> List[Dict]:
"""Get tracks from discovery pool matching genre or artist""" """Get tracks from discovery pool matching genre or artist"""
# Determine active source
active_source = self._get_active_source()
try: try:
with self.database._get_connection() as conn: with self.database._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
@ -776,32 +788,23 @@ class PersonalizedPlaylistsService:
cursor.execute(""" cursor.execute("""
SELECT SELECT
spotify_track_id, spotify_track_id,
itunes_track_id,
track_name, track_name,
artist_name, artist_name,
album_name, album_name,
album_cover_url, album_cover_url,
duration_ms, duration_ms,
popularity, popularity,
track_data_json track_data_json,
source
FROM discovery_pool FROM discovery_pool
WHERE artist_name LIKE ? OR track_name LIKE ? WHERE (artist_name LIKE ? OR track_name LIKE ?) AND source = ?
ORDER BY RANDOM() ORDER BY RANDOM()
LIMIT ? LIMIT ?
""", (f'%{category}%', f'%{category}%', limit)) """, (f'%{category}%', f'%{category}%', active_source, limit))
rows = cursor.fetchall() rows = cursor.fetchall()
tracks = [] return [self._build_track_dict(row, active_source) for row in rows]
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
except Exception as e: except Exception as e:
logger.error(f"Error getting discovery tracks by category: {e}") logger.error(f"Error getting discovery tracks by category: {e}")

View file

@ -557,20 +557,18 @@ class WatchlistScanner:
self.update_artist_scan_timestamp(db_artist_id) self.update_artist_scan_timestamp(db_artist_id)
# Fetch and store similar artists for discovery feature (with caching to avoid over-polling) # Fetch and store similar artists for discovery feature (with caching to avoid over-polling)
# Note: Similar artists feature only works with Spotify # Similar artists are fetched from MusicMap (works with any source) and matched to both Spotify and iTunes
if provider == 'spotify' and watchlist_artist.spotify_artist_id: source_artist_id = watchlist_artist.spotify_artist_id or watchlist_artist.itunes_artist_id or str(watchlist_artist.id)
try: try:
# Check if we have fresh similar artists cached (< 30 days old) # 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): 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") logger.info(f"Similar artists for {watchlist_artist.artist_name} are cached and fresh, skipping fetch")
else: else:
logger.info(f"Fetching similar artists for {watchlist_artist.artist_name}...") logger.info(f"Fetching similar artists for {watchlist_artist.artist_name}...")
self.update_similar_artists(watchlist_artist) self.update_similar_artists(watchlist_artist)
logger.info(f"Similar artists updated for {watchlist_artist.artist_name}") logger.info(f"Similar artists updated for {watchlist_artist.artist_name}")
except Exception as similar_error: except Exception as similar_error:
logger.warning(f"Failed to update similar artists for {watchlist_artist.artist_name}: {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)")
return ScanResult( return ScanResult(
artist_name=watchlist_artist.artist_name, 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]]: 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: Args:
artist_name: The artist name to find similar artists for artist_name: The artist name to find similar artists for
limit: Maximum number of similar artists to return (default: 20) limit: Maximum number of similar artists to return (default: 20)
Returns: Returns:
List of matched artist dictionaries with Spotify data List of matched artist dictionaries with both Spotify and iTunes IDs when available
""" """
try: try:
logger.info(f"Fetching similar artists from MusicMap for: {artist_name}") 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") logger.info(f"Found {len(similar_artist_names)} similar artists from MusicMap")
# Get the searched artist's Spotify ID to exclude them # Get iTunes client for matching
searched_artist_id = None from core.itunes_client import iTunesClient
try: itunes_client = iTunesClient()
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}")
# 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 = [] 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]: for artist_name_to_match in similar_artist_names[:limit]:
try: try:
# Search Spotify for the artist # Skip if we've already matched this artist name
results = self.spotify_client.search_artists(artist_name_to_match, limit=1) name_lower = artist_name_to_match.lower().strip()
if name_lower in seen_names:
continue
if results and len(results) > 0: artist_data = {
spotify_artist = results[0] 'name': artist_name_to_match,
'spotify_id': None,
'itunes_id': None,
'image_url': None,
'genres': [],
'popularity': 0
}
# Skip if this is the searched artist # Try to match on Spotify
if spotify_artist.id == searched_artist_id: if self.spotify_client and self.spotify_client.is_spotify_authenticated():
continue 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) # Try to match on iTunes
if spotify_artist.id in seen_artist_ids: try:
continue 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) # Only add if we got at least one ID
if artist_data['spotify_id'] or artist_data['itunes_id']:
matched_artists.append({ seen_names.add(name_lower)
'id': spotify_artist.id, matched_artists.append(artist_data)
'name': spotify_artist.name, logger.debug(f" Matched: {artist_data['name']} (Spotify: {artist_data['spotify_id']}, iTunes: {artist_data['itunes_id']})")
'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}")
except Exception as match_error: except Exception as match_error:
logger.debug(f"Error matching {artist_name_to_match}: {match_error}") logger.debug(f"Error matching {artist_name_to_match}: {match_error}")
continue 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 return matched_artists
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
@ -1210,12 +1250,12 @@ class WatchlistScanner:
""" """
Fetch and store similar artists for a watchlist artist. Fetch and store similar artists for a watchlist artist.
Called after each artist scan to build discovery pool. 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: try:
logger.info(f"Fetching similar artists for {watchlist_artist.artist_name}") 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) similar_artists = self._fetch_similar_artists_from_musicmap(watchlist_artist.artist_name, limit=limit)
if not similar_artists: if not similar_artists:
@ -1224,21 +1264,25 @@ class WatchlistScanner:
logger.info(f"Found {len(similar_artists)} similar artists for {watchlist_artist.artist_name}") 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 # Store each similar artist in database
stored_count = 0 stored_count = 0
for rank, similar_artist in enumerate(similar_artists, 1): for rank, similar_artist in enumerate(similar_artists, 1):
try: 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( success = self.database.add_or_update_similar_artist(
source_artist_id=watchlist_artist.spotify_artist_id, source_artist_id=source_artist_id,
similar_artist_spotify_id=similar_artist['id'],
similar_artist_name=similar_artist['name'], 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 similarity_rank=rank
) )
if success: if success:
stored_count += 1 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: except Exception as e:
logger.warning(f"Error storing similar artist {similar_artist.get('name', 'Unknown')}: {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. Populate discovery pool with tracks from top similar artists.
Called after watchlist scan completes. Called after watchlist scan completes.
IMPROVED: Larger pool for better discovery (50 artists x 10 releases = ~500 releases) Supports both Spotify and iTunes sources - populates for whichever is available.
- Checks if pool was updated in last 24 hours (prevents over-polling Spotify) - Checks if pool was updated in last 24 hours (prevents over-polling)
- Includes albums, singles, and EPs for comprehensive coverage - Includes albums, singles, and EPs for comprehensive coverage
- Appends to existing pool instead of replacing it - Appends to existing pool instead of replacing it
- Cleans up tracks older than 365 days (maintains 1 year rolling window) - Cleans up tracks older than 365 days (maintains 1 year rolling window)
@ -1266,13 +1310,27 @@ class WatchlistScanner:
from datetime import datetime, timedelta from datetime import datetime, timedelta
import random 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): 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 return
logger.info("Populating discovery pool from similar artists...") 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) # Get top similar artists across all watchlist (ordered by occurrence_count)
similar_artists = self.database.get_top_similar_artists(limit=top_artists_limit) similar_artists = self.database.get_top_similar_artists(limit=top_artists_limit)
@ -1288,129 +1346,182 @@ class WatchlistScanner:
try: try:
logger.info(f"[{artist_idx}/{len(similar_artists)}] Processing {similar_artist.similar_artist_name} (occurrence: {similar_artist.occurrence_count})") 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 # Build list of sources to process for this artist
all_albums = self.spotify_client.get_artist_albums( # iTunes is ALWAYS processed (baseline), Spotify is added if authenticated
similar_artist.similar_artist_spotify_id, sources_to_process = []
album_type='album,single,ep', # Include albums, singles, and EPs for comprehensive discovery
limit=50
)
if not all_albums: # Always add iTunes first (baseline source)
logger.debug(f"No albums found for {similar_artist.similar_artist_name}") 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 continue
# Fetch artist genres once for all tracks of this artist logger.debug(f" Processing {len(sources_to_process)} source(s): {[s[0] for s in sources_to_process]}")
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}")
# IMPROVED: Smart selection mixing albums, singles, and EPs # Process each source for this artist
# Prioritize recent releases and popular content for source, artist_id in sources_to_process:
# 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):
try: try:
# Get full album data with tracks # Get artist's albums from this source
album_data = self.spotify_client.get_album(album.id) 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 continue
tracks = album_data['tracks'].get('items', []) # Fetch artist genres for this source
logger.debug(f" Album {album_idx}: {album_data.get('name', 'Unknown')} ({len(tracks)} tracks)") artist_genres = []
# Determine if this is a new release (within last 30 days)
is_new = False
try: try:
release_date_str = album_data.get('release_date', '') if source == 'spotify':
if release_date_str: artist_data = self.spotify_client.get_artist(artist_id)
if len(release_date_str) == 10: # Full date if artist_data and 'genres' in artist_data:
release_date = datetime.strptime(release_date_str, "%Y-%m-%d") artist_genres = artist_data['genres']
days_old = (datetime.now() - release_date).days else: # iTunes - genres from artist lookup
is_new = days_old <= 30 artist_data = itunes_client.get_artist(artist_id)
except: if artist_data and 'genres' in artist_data:
pass 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 # IMPROVED: Smart selection mixing albums, singles, and EPs
for track in tracks: # 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: try:
# Enhance track object with full album data (including album_type) # Get full album data with tracks from appropriate source
enhanced_track = { if source == 'spotify':
**track, album_data = self.spotify_client.get_album(album.id)
'album': { if not album_data or 'tracks' not in album_data:
'id': album_data['id'], continue
'name': album_data.get('name', 'Unknown Album'), tracks = album_data['tracks'].get('items', [])
'images': album_data.get('images', []), else: # itunes
'release_date': album_data.get('release_date', ''), album_data = itunes_client.get_album(album.id)
'album_type': album_data.get('album_type', 'album'), if not album_data:
'total_tracks': album_data.get('total_tracks', 0) 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 logger.debug(f" Album {album_idx}: {album_data.get('name', 'Unknown')} ({len(tracks)} tracks)")
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
}
# Add to discovery pool # Determine if this is a new release (within last 30 days)
if self.database.add_to_discovery_pool(track_data): is_new = False
total_tracks_added += 1 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: # Add each track to discovery pool
logger.debug(f"Error adding track to discovery pool: {track_error}") 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 continue
# Small delay between albums except Exception as source_error:
time.sleep(DELAY_BETWEEN_ALBUMS) logger.warning(f"Error processing {source} source for {similar_artist.similar_artist_name}: {source_error}")
except Exception as album_error:
logger.warning(f"Error processing album: {album_error}")
continue continue
# Delay between artists # Delay between artists (after processing all sources for this artist)
if artist_idx < len(similar_artists): if artist_idx < len(similar_artists):
time.sleep(DELAY_BETWEEN_ARTISTS) time.sleep(DELAY_BETWEEN_ARTISTS)
@ -1441,76 +1552,115 @@ class WatchlistScanner:
for db_idx, album_row in enumerate(db_albums, 1): for db_idx, album_row in enumerate(db_albums, 1):
try: try:
# Search for album on Spotify query = f"{album_row['title']} {album_row['artist_name']}"
query = f"album:{album_row['title']} artist:{album_row['artist_name']}" album_data = None
search_results = self.spotify_client.search_albums(query, limit=1) tracks = []
db_source = None
artist_id_for_genres = None
if search_results and len(search_results) > 0: # Try Spotify first if available
spotify_album = search_results[0] if spotify_available:
album_data = self.spotify_client.get_album(spotify_album.id) 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: # Fall back to iTunes if Spotify didn't work
tracks = album_data['tracks'].get('items', []) 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 if not tracks or not album_data:
artist_genres = [] continue
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}")
# Check if new release # Fetch artist genres
is_new = False artist_genres = []
try: try:
release_date_str = album_data.get('release_date', '') if artist_id_for_genres:
if release_date_str and len(release_date_str) == 10: if db_source == 'spotify':
release_date = datetime.strptime(release_date_str, "%Y-%m-%d") artist_data = self.spotify_client.get_artist(artist_id_for_genres)
days_old = (datetime.now() - release_date).days else:
is_new = days_old <= 30 artist_data = itunes_client.get_artist(artist_id_for_genres)
except: if artist_data and 'genres' in artist_data:
pass artist_genres = artist_data['genres']
except Exception as e:
logger.debug(f"Could not fetch genres for album artist: {e}")
for track in tracks: # Check if new release
try: is_new = False
# Enhance track object with full album data (including album_type) try:
enhanced_track = { release_date_str = album_data.get('release_date', '')
**track, if release_date_str and len(release_date_str) >= 10:
'album': { release_date = datetime.strptime(release_date_str[:10], "%Y-%m-%d")
'id': album_data['id'], days_old = (datetime.now() - release_date).days
'name': album_row['title'], is_new = days_old <= 30
'images': album_data.get('images', []), except:
'release_date': album_data.get('release_date', ''), pass
'album_type': album_data.get('album_type', 'album'),
'total_tracks': album_data.get('total_tracks', 0)
}
}
track_data = { for track in tracks:
'spotify_track_id': track['id'], try:
'spotify_album_id': album_data['id'], enhanced_track = {
'spotify_artist_id': album_data['artists'][0]['id'] if album_data.get('artists') else '', **track,
'track_name': track['name'], 'album': {
'artist_name': album_row['artist_name'], 'id': album_data['id'],
'album_name': album_row['title'], 'name': album_row['title'],
'album_cover_url': album_data.get('images', [{}])[0].get('url') if album_data.get('images') else None, 'images': album_data.get('images', []),
'duration_ms': track.get('duration_ms', 0), 'release_date': album_data.get('release_date', ''),
'popularity': album_data.get('popularity', 0), 'album_type': album_data.get('album_type', 'album'),
'release_date': album_data.get('release_date', ''), 'total_tracks': album_data.get('total_tracks', 0)
'is_new_release': is_new, },
'track_data_json': enhanced_track, # Store enhanced track with full album data '_source': db_source
'artist_genres': artist_genres }
}
if self.database.add_to_discovery_pool(track_data): track_data = {
total_tracks_added += 1 'track_name': track.get('name', 'Unknown Track'),
except Exception as track_error: 'artist_name': album_row['artist_name'],
continue '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: except Exception as album_error:
logger.debug(f"Error processing database album {album_row['title']}: {album_error}") logger.debug(f"Error processing database album {album_row['title']}: {album_error}")
continue continue

View file

@ -97,10 +97,11 @@ class WatchlistArtist:
@dataclass @dataclass
class SimilarArtist: class SimilarArtist:
"""Similar artist recommendation from Spotify""" """Similar artist recommendation from Spotify/iTunes"""
id: int id: int
source_artist_id: str # Watchlist artist's database ID 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 similar_artist_name: str
similarity_rank: int # 1-10, where 1 is most similar similarity_rank: int # 1-10, where 1 is most similar
occurrence_count: int # How many watchlist artists share this similar artist occurrence_count: int # How many watchlist artists share this similar artist
@ -110,9 +111,13 @@ class SimilarArtist:
class DiscoveryTrack: class DiscoveryTrack:
"""Track in the discovery pool for recommendations""" """Track in the discovery pool for recommendations"""
id: int id: int
spotify_track_id: str spotify_track_id: Optional[str] # Spotify track ID (None if iTunes source)
spotify_album_id: str spotify_album_id: Optional[str] # Spotify album ID (None if iTunes source)
spotify_artist_id: str 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 track_name: str
artist_name: str artist_name: str
album_name: str album_name: str
@ -121,7 +126,7 @@ class DiscoveryTrack:
popularity: int popularity: int
release_date: str release_date: str
is_new_release: bool # Released within last 30 days 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 added_date: datetime
@dataclass @dataclass
@ -129,7 +134,9 @@ class RecentRelease:
"""Recent album release from watchlist artist""" """Recent album release from watchlist artist"""
id: int id: int
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 album_name: str
release_date: str release_date: str
album_cover_url: Optional[str] album_cover_url: Optional[str]
@ -450,26 +457,33 @@ class MusicDatabase:
"""Add tables for discovery feature: similar artists, discovery pool, and recent releases""" """Add tables for discovery feature: similar artists, discovery pool, and recent releases"""
try: try:
# Similar Artists table - stores similar artists for each watchlist artist # Similar Artists table - stores similar artists for each watchlist artist
# Supports both Spotify and iTunes IDs for dual-source discovery
cursor.execute(""" cursor.execute("""
CREATE TABLE IF NOT EXISTS similar_artists ( CREATE TABLE IF NOT EXISTS similar_artists (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
source_artist_id TEXT NOT NULL, 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, similar_artist_name TEXT NOT NULL,
similarity_rank INTEGER DEFAULT 1, similarity_rank INTEGER DEFAULT 1,
occurrence_count INTEGER DEFAULT 1, occurrence_count INTEGER DEFAULT 1,
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 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 # Discovery Pool table - rotating pool of 1000-2000 tracks for recommendations
# Supports both Spotify and iTunes sources for dual-source discovery
cursor.execute(""" cursor.execute("""
CREATE TABLE IF NOT EXISTS discovery_pool ( CREATE TABLE IF NOT EXISTS discovery_pool (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
spotify_track_id TEXT UNIQUE NOT NULL, spotify_track_id TEXT,
spotify_album_id TEXT NOT NULL, spotify_album_id TEXT,
spotify_artist_id TEXT NOT NULL, 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, track_name TEXT NOT NULL,
artist_name TEXT NOT NULL, artist_name TEXT NOT NULL,
album_name TEXT NOT NULL, album_name TEXT NOT NULL,
@ -479,38 +493,47 @@ class MusicDatabase:
release_date TEXT, release_date TEXT,
is_new_release BOOLEAN DEFAULT 0, is_new_release BOOLEAN DEFAULT 0,
track_data_json TEXT NOT NULL, 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 # Recent Releases table - tracks new releases from watchlist artists
# Supports both Spotify and iTunes sources for dual-source discovery
cursor.execute(""" cursor.execute("""
CREATE TABLE IF NOT EXISTS recent_releases ( CREATE TABLE IF NOT EXISTS recent_releases (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
watchlist_artist_id INTEGER NOT NULL, 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, album_name TEXT NOT NULL,
release_date TEXT NOT NULL, release_date TEXT NOT NULL,
album_cover_url TEXT, album_cover_url TEXT,
track_count INTEGER DEFAULT 0, track_count INTEGER DEFAULT 0,
added_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 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 FOREIGN KEY (watchlist_artist_id) REFERENCES watchlist_artists (id) ON DELETE CASCADE
) )
""") """)
# Discovery Recent Albums cache - for discover page recent releases section # Discovery Recent Albums cache - for discover page recent releases section
# Supports both Spotify and iTunes sources for dual-source discovery
cursor.execute(""" cursor.execute("""
CREATE TABLE IF NOT EXISTS discovery_recent_albums ( CREATE TABLE IF NOT EXISTS discovery_recent_albums (
id INTEGER PRIMARY KEY AUTOINCREMENT, 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, album_name TEXT NOT NULL,
artist_name TEXT NOT NULL, artist_name TEXT NOT NULL,
artist_spotify_id TEXT NOT NULL,
album_cover_url TEXT, album_cover_url TEXT,
release_date TEXT NOT NULL, release_date TEXT NOT NULL,
album_type TEXT DEFAULT 'album', 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 # 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_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_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_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_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_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_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_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_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_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_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_type ON listenbrainz_playlists (playlist_type)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_listenbrainz_playlists_mbid ON listenbrainz_playlists (playlist_mbid)") 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") cursor.execute("ALTER TABLE discovery_pool ADD COLUMN artist_genres TEXT")
logger.info("Added artist_genres column to discovery_pool table") 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") logger.info("Discovery tables created successfully")
except Exception as e: except Exception as e:
@ -2933,23 +2998,28 @@ class MusicDatabase:
# === Discovery Feature Methods === # === Discovery Feature Methods ===
def add_or_update_similar_artist(self, source_artist_id: str, similar_artist_spotify_id: str, def add_or_update_similar_artist(self, source_artist_id: str, similar_artist_name: str,
similar_artist_name: str, similarity_rank: int = 1) -> bool: similar_artist_spotify_id: Optional[str] = None,
"""Add or update a similar artist recommendation""" 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: try:
with self._get_connection() as conn: with self._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
# Use artist name as the unique key (allows storing both IDs for same artist)
cursor.execute(""" cursor.execute("""
INSERT INTO similar_artists INSERT INTO similar_artists
(source_artist_id, similar_artist_spotify_id, similar_artist_name, similarity_rank, occurrence_count, last_updated) (source_artist_id, similar_artist_spotify_id, similar_artist_itunes_id, similar_artist_name, similarity_rank, occurrence_count, last_updated)
VALUES (?, ?, ?, ?, 1, CURRENT_TIMESTAMP) VALUES (?, ?, ?, ?, ?, 1, CURRENT_TIMESTAMP)
ON CONFLICT(source_artist_id, similar_artist_spotify_id) ON CONFLICT(source_artist_id, similar_artist_name)
DO UPDATE SET 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, similarity_rank = excluded.similarity_rank,
occurrence_count = occurrence_count + 1, occurrence_count = occurrence_count + 1,
last_updated = CURRENT_TIMESTAMP 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() conn.commit()
return True return True
@ -2975,6 +3045,7 @@ class MusicDatabase:
id=row['id'], id=row['id'],
source_artist_id=row['source_artist_id'], source_artist_id=row['source_artist_id'],
similar_artist_spotify_id=row['similar_artist_spotify_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'], similar_artist_name=row['similar_artist_name'],
similarity_rank=row['similarity_rank'], similarity_rank=row['similarity_rank'],
occurrence_count=row['occurrence_count'], occurrence_count=row['occurrence_count'],
@ -3026,13 +3097,14 @@ class MusicDatabase:
SELECT SELECT
MAX(id) as id, MAX(id) as id,
MAX(source_artist_id) as source_artist_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, similar_artist_name,
AVG(similarity_rank) as similarity_rank, AVG(similarity_rank) as similarity_rank,
SUM(occurrence_count) as occurrence_count, SUM(occurrence_count) as occurrence_count,
MAX(last_updated) as last_updated MAX(last_updated) as last_updated
FROM similar_artists 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 ORDER BY occurrence_count DESC, similarity_rank ASC
LIMIT ? LIMIT ?
""", (limit,)) """, (limit,))
@ -3042,6 +3114,7 @@ class MusicDatabase:
id=row['id'], id=row['id'],
source_artist_id=row['source_artist_id'], source_artist_id=row['source_artist_id'],
similar_artist_spotify_id=row['similar_artist_spotify_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'], similar_artist_name=row['similar_artist_name'],
similarity_rank=int(row['similarity_rank']), similarity_rank=int(row['similarity_rank']),
occurrence_count=row['occurrence_count'], occurrence_count=row['occurrence_count'],
@ -3052,15 +3125,24 @@ class MusicDatabase:
logger.error(f"Error getting top similar artists: {e}") logger.error(f"Error getting top similar artists: {e}")
return [] return []
def add_to_discovery_pool(self, track_data: Dict[str, Any]) -> bool: def add_to_discovery_pool(self, track_data: Dict[str, Any], source: str = 'spotify') -> bool:
"""Add a track to the discovery pool""" """Add a track to the discovery pool (supports both Spotify and iTunes sources)"""
try: try:
with self._get_connection() as conn: with self._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
# Check if track already exists # Check if track already exists based on source
cursor.execute("SELECT COUNT(*) as count FROM discovery_pool WHERE spotify_track_id = ?", if source == 'spotify' and track_data.get('spotify_track_id'):
(track_data['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: if cursor.fetchone()['count'] > 0:
return True # Already in pool return True # Already in pool
@ -3070,14 +3152,19 @@ class MusicDatabase:
cursor.execute(""" cursor.execute("""
INSERT INTO discovery_pool INSERT INTO discovery_pool
(spotify_track_id, spotify_album_id, spotify_artist_id, track_name, artist_name, (spotify_track_id, spotify_album_id, spotify_artist_id,
album_name, album_cover_url, duration_ms, popularity, release_date, itunes_track_id, itunes_album_id, itunes_artist_id,
is_new_release, track_data_json, artist_genres, added_date) source, track_name, artist_name, album_name, album_cover_url,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) 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.get('spotify_track_id'),
track_data['spotify_album_id'], track_data.get('spotify_album_id'),
track_data['spotify_artist_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['track_name'],
track_data['artist_name'], track_data['artist_name'],
track_data['album_name'], track_data['album_name'],
@ -3124,32 +3211,45 @@ class MusicDatabase:
except Exception as e: except Exception as e:
logger.error(f"Error rotating discovery pool: {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]: 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""" """Get tracks from discovery pool, optionally filtered by source ('spotify' or 'itunes')"""
try: try:
with self._get_connection() as conn: with self._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
# Build query with optional source filter
where_clauses = []
params = []
if new_releases_only: if new_releases_only:
cursor.execute(""" where_clauses.append("is_new_release = 1")
SELECT * FROM discovery_pool
WHERE is_new_release = 1 if source:
ORDER BY added_date DESC where_clauses.append("source = ?")
LIMIT ? params.append(source)
""", (limit,))
else: where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
cursor.execute(""" params.append(limit)
SELECT * FROM discovery_pool
ORDER BY added_date DESC cursor.execute(f"""
LIMIT ? SELECT * FROM discovery_pool
""", (limit,)) {where_sql}
ORDER BY added_date DESC
LIMIT ?
""", params)
rows = cursor.fetchall() rows = cursor.fetchall()
row_keys = rows[0].keys() if rows else []
return [DiscoveryTrack( return [DiscoveryTrack(
id=row['id'], id=row['id'],
spotify_track_id=row['spotify_track_id'], spotify_track_id=row['spotify_track_id'],
spotify_album_id=row['spotify_album_id'], spotify_album_id=row['spotify_album_id'],
spotify_artist_id=row['spotify_artist_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'], track_name=row['track_name'],
artist_name=row['artist_name'], artist_name=row['artist_name'],
album_name=row['album_name'], album_name=row['album_name'],
@ -3166,21 +3266,25 @@ class MusicDatabase:
logger.error(f"Error getting discovery pool tracks: {e}") logger.error(f"Error getting discovery pool tracks: {e}")
return [] return []
def cache_discovery_recent_album(self, album_data: Dict[str, Any]) -> bool: def cache_discovery_recent_album(self, album_data: Dict[str, Any], source: str = 'spotify') -> bool:
"""Cache a recent album for the discover page (from watchlist or similar artists)""" """Cache a recent album for the discover page (supports both Spotify and iTunes sources)"""
try: try:
with self._get_connection() as conn: with self._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(""" cursor.execute("""
INSERT OR REPLACE INTO discovery_recent_albums 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) (album_spotify_id, album_itunes_id, artist_spotify_id, artist_itunes_id, source,
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) 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['album_name'],
album_data['artist_name'], album_data['artist_name'],
album_data['artist_spotify_id'],
album_data.get('album_cover_url'), album_data.get('album_cover_url'),
album_data['release_date'], album_data['release_date'],
album_data.get('album_type', 'album') album_data.get('album_type', 'album')
@ -3193,27 +3297,40 @@ class MusicDatabase:
logger.error(f"Error caching discovery recent album: {e}") logger.error(f"Error caching discovery recent album: {e}")
return False return False
def get_discovery_recent_albums(self, limit: int = 10) -> List[Dict[str, Any]]: def get_discovery_recent_albums(self, limit: int = 10, source: Optional[str] = None) -> List[Dict[str, Any]]:
"""Get cached recent albums for discover page""" """Get cached recent albums for discover page, optionally filtered by source"""
try: try:
with self._get_connection() as conn: with self._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(""" if source:
SELECT * FROM discovery_recent_albums cursor.execute("""
ORDER BY release_date DESC SELECT * FROM discovery_recent_albums
LIMIT ? WHERE source = ?
""", (limit,)) 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() rows = cursor.fetchall()
row_keys = rows[0].keys() if rows else []
return [{ return [{
'album_spotify_id': row['album_spotify_id'], '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'], 'album_name': row['album_name'],
'artist_name': row['artist_name'], 'artist_name': row['artist_name'],
'artist_spotify_id': row['artist_spotify_id'], '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'], 'album_cover_url': row['album_cover_url'],
'release_date': row['release_date'], '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] } for row in rows]
except Exception as e: except Exception as e:

View file

@ -5046,19 +5046,73 @@ def get_artist_image(artist_id):
def get_artist_discography(artist_id): def get_artist_discography(artist_id):
"""Get an artist's complete discography (albums and singles)""" """Get an artist's complete discography (albums and singles)"""
try: try:
if not spotify_client or not spotify_client.is_authenticated(): # Get optional artist name for fallback searches
return jsonify({"error": "Spotify not authenticated"}), 401 artist_name = request.args.get('artist_name', '')
print(f"🎤 Fetching discography for artist: {artist_id}") # Determine which source to use
spotify_available = spotify_client and spotify_client.is_spotify_authenticated()
# Get artist's albums and singles (temporarily include appears_on for debugging) # Import iTunes client for fallback
albums = spotify_client.get_artist_albums(artist_id, album_type='album,single', limit=50) from core.itunes_client import iTunesClient
print(f"📊 Raw albums returned from Spotify: {len(albums)}") 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: if not albums:
return jsonify({ return jsonify({
"albums": [], "albums": [],
"singles": [] "singles": [],
"source": active_source or "unknown"
}) })
# Separate albums from singles/EPs # Separate albums from singles/EPs
@ -5074,22 +5128,17 @@ def get_artist_discography(artist_id):
continue continue
seen_albums.add(album.id) seen_albums.add(album.id)
# Debug: Check artist information # Handle artist matching for both Spotify (objects) and iTunes (strings) formats
print(f"🔍 Checking album: {album.name}")
if hasattr(album, 'artists') and album.artists: if hasattr(album, 'artists') and album.artists:
primary_artist_id = album.artists[0].id if hasattr(album.artists[0], 'id') else None first_artist = album.artists[0]
primary_artist_name = album.artists[0].name if hasattr(album.artists[0], 'name') else None # Check if artists are objects (Spotify) or strings (iTunes)
print(f" Primary artist: {primary_artist_name} (ID: {primary_artist_id})") if hasattr(first_artist, 'id'):
print(f" Requested artist ID: {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 # Skip if the primary artist doesn't match our requested artist
if primary_artist_id and primary_artist_id != artist_id: if primary_artist_id and primary_artist_id != artist_id:
print(f"🚫 Skipping '{album.name}' - primary artist mismatch") continue
continue # iTunes format: artist is a string, can't verify ID match so include all
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")
album_data = { album_data = {
"id": album.id, "id": album.id,
@ -5138,7 +5187,8 @@ def get_artist_discography(artist_id):
return jsonify({ return jsonify({
"albums": album_list, "albums": album_list,
"singles": singles_list "singles": singles_list,
"source": active_source or "spotify"
}) })
except Exception as e: except Exception as e:
@ -18178,84 +18228,141 @@ metadata_update_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix=
# == DISCOVER PAGE ENDPOINTS == # == 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']) @app.route('/api/discover/hero', methods=['GET'])
def get_discover_hero(): def get_discover_hero():
"""Get featured similar artists for hero slideshow""" """Get featured similar artists for hero slideshow"""
try: try:
database = get_database() 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 # Get top similar artists (by occurrence count) - get 20 for variety
similar_artists = database.get_top_similar_artists(limit=20) similar_artists = database.get_top_similar_artists(limit=20)
if not similar_artists: 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 # Shuffle for variety and take top 10
import random import random
shuffled = list(similar_artists) shuffled = list(valid_artists)
random.shuffle(shuffled) random.shuffle(shuffled)
similar_artists = shuffled[:10] 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 = [] hero_artists = []
for artist in similar_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 = { artist_data = {
"spotify_artist_id": artist.similar_artist_spotify_id, "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, "artist_name": artist.similar_artist_name,
"occurrence_count": artist.occurrence_count, "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: try:
if spotify_client and spotify_client.is_authenticated(): if active_source == 'spotify' and artist.similar_artist_spotify_id:
sp_artist = spotify_client.get_artist(artist.similar_artist_spotify_id) if spotify_client and spotify_client.is_authenticated():
if sp_artist and sp_artist.get('images'): sp_artist = spotify_client.get_artist(artist.similar_artist_spotify_id)
artist_data['image_url'] = sp_artist['images'][0]['url'] if sp_artist['images'] else None if sp_artist and sp_artist.get('images'):
artist_data['genres'] = sp_artist.get('genres', []) artist_data['image_url'] = sp_artist['images'][0]['url'] if sp_artist['images'] else None
artist_data['popularity'] = sp_artist.get('popularity', 0) artist_data['genres'] = sp_artist.get('genres', [])
except: artist_data['popularity'] = sp_artist.get('popularity', 0)
pass 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) 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: except Exception as e:
print(f"Error getting discover hero: {e}") print(f"Error getting discover hero: {e}")
return jsonify({"success": False, "error": str(e)}), 500 return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/discover/recent-releases', methods=['GET']) @app.route('/api/discover/recent-releases', methods=['GET'])
def get_discover_recent_releases(): def get_discover_recent_releases():
"""Get cached recent albums from watchlist and similar artists""" """Get cached recent albums from watchlist and similar artists"""
try: try:
database = get_database() database = get_database()
# Get cached recent albums (max 20) # Determine active source
albums = database.get_discovery_recent_albums(limit=20) 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: except Exception as e:
print(f"Error getting recent releases: {e}") print(f"Error getting recent releases: {e}")
return jsonify({"success": False, "error": str(e)}), 500 return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/discover/release-radar', methods=['GET']) @app.route('/api/discover/release-radar', methods=['GET'])
def get_discover_release_radar(): def get_discover_release_radar():
"""Get release radar playlist - curated selection that stays consistent until next update""" """Get release radar playlist - curated selection that stays consistent until next update"""
try: try:
database = get_database() database = get_database()
if not spotify_client or not spotify_client.is_authenticated(): # Determine active source - release radar works with any source now
return jsonify({"success": True, "tracks": []}) active_source = _get_active_discovery_source()
# Try to get curated playlist first # Try to get curated playlist first
curated_track_ids = database.get_curated_playlist('release_radar') curated_track_ids = database.get_curated_playlist('release_radar')
if curated_track_ids: if curated_track_ids:
# Use curated selection - fetch track data from discovery pool (same as Discovery Weekly) # 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) discovery_tracks = database.get_discovery_pool_tracks(limit=5000, new_releases_only=False, source=active_source)
tracks_by_id = {track.spotify_track_id: track for track in discovery_tracks}
# 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 = [] selected_tracks = []
for track_id in curated_track_ids: for track_id in curated_track_ids:
@ -18271,19 +18378,22 @@ def get_discover_release_radar():
track_data = None track_data = None
selected_tracks.append({ selected_tracks.append({
"track_id": track.spotify_track_id or track.itunes_track_id,
"spotify_track_id": track.spotify_track_id, "spotify_track_id": track.spotify_track_id,
"itunes_track_id": track.itunes_track_id,
"track_name": track.track_name, "track_name": track.track_name,
"artist_name": track.artist_name, "artist_name": track.artist_name,
"album_name": track.album_name, "album_name": track.album_name,
"album_cover_url": track.album_cover_url, "album_cover_url": track.album_cover_url,
"duration_ms": track.duration_ms, "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) # 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: except Exception as e:
print(f"Error getting release radar: {e}") print(f"Error getting release radar: {e}")
@ -18297,13 +18407,23 @@ def get_discover_weekly():
try: try:
database = get_database() database = get_database()
# Determine active source
active_source = _get_active_discovery_source()
# Try to get curated playlist first # Try to get curated playlist first
curated_track_ids = database.get_curated_playlist('discovery_weekly') curated_track_ids = database.get_curated_playlist('discovery_weekly')
if curated_track_ids: if curated_track_ids:
# Use curated selection - fetch track data from discovery pool # 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) discovery_tracks = database.get_discovery_pool_tracks(limit=5000, new_releases_only=False, source=active_source)
tracks_by_id = {track.spotify_track_id: track for track in discovery_tracks}
# 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 = [] selected_tracks = []
for track_id in curated_track_ids: for track_id in curated_track_ids:
@ -18319,19 +18439,22 @@ def get_discover_weekly():
track_data = None track_data = None
selected_tracks.append({ selected_tracks.append({
"track_id": track.spotify_track_id or track.itunes_track_id,
"spotify_track_id": track.spotify_track_id, "spotify_track_id": track.spotify_track_id,
"itunes_track_id": track.itunes_track_id,
"track_name": track.track_name, "track_name": track.track_name,
"artist_name": track.artist_name, "artist_name": track.artist_name,
"album_name": track.album_name, "album_name": track.album_name,
"album_cover_url": track.album_cover_url, "album_cover_url": track.album_cover_url,
"duration_ms": track.duration_ms, "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) # 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: except Exception as e:
print(f"Error getting discovery weekly: {e}") print(f"Error getting discovery weekly: {e}")

View file

@ -20000,15 +20000,17 @@ async function selectArtistForDetail(artist) {
// Update artist info in header // Update artist info in header
updateArtistDetailHeader(artist); updateArtistDetailHeader(artist);
// Load discography // Load discography (pass artist name for cross-source fallback)
await loadArtistDiscography(artist.id); 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) { async function loadArtistDiscography(artistId, artistName = null) {
console.log(`💿 Loading discography for artist: ${artistId}`); console.log(`💿 Loading discography for artist: ${artistId} (name: ${artistName})`);
// Check cache first // Check cache first
if (artistsPageState.cache.discography[artistId]) { if (artistsPageState.cache.discography[artistId]) {
@ -20030,8 +20032,14 @@ async function loadArtistDiscography(artistId) {
// Show loading states // Show loading states
showDiscographyLoading(); 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 // Call the real API endpoint
const response = await fetch(`/api/artist/${artistId}/discography`); const response = await fetch(url);
if (!response.ok) { if (!response.ok) {
if (response.status === 401) { if (response.status === 401) {