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.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}")

View file

@ -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

View file

@ -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:

View file

@ -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}")

View file

@ -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) {