Merge pull request #126 from Nezreka/itunes

Apple Music added as fallback metadata source if Spotify is not available. Spotify is always preferred if it is available with its richer data responses. Will easily swap from Spotify to Apple Music on the fly if you are rate limited by Spotify or worse, temp banned. 

Apple Music and Spotify will now each have their own discovery pool for the discover page. Both will always be updated on every watchlist scan so long as Spotify is authorized, otherwise only Apple Music data is pulled.

**Known issues:**

Any artist image pulled while Apple Music is the primary source will only pull album art for that artist since they do not provide artist images :(  This can, very rarely, lead to cases where the album image that is pulled could have another another artist displayed if it's some collab single, EP or something. Seen it happen once with an indie artist so it's possible.

Looking for a great api / website to parse easily specifically for artist names and a huge db to pull from.
This commit is contained in:
Broque Thomas 2026-01-25 11:07:50 -08:00 committed by GitHub
commit 61618c2fc7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 4296 additions and 1451 deletions

View file

@ -59,6 +59,15 @@ SoulSync bridges streaming services to your media server with automated discover
- Batch processing with retry logic - Batch processing with retry logic
- Synchronized lyrics (LRC) for every track - Synchronized lyrics (LRC) for every track
### Metadata & Reliability
**Dual-Source System**
- **Primary**: Spotify (Preferred for richer data and discovery features)
- **Backup**: iTunes (No authentication required)
- **Redundancy**: System automatically manages both sources. If Spotify is authorized, it is prioritized. If Spotify is unavailable, rate-limited, or unauthorized, SoulSync **seamlessly switches to iTunes** for metadata, cover art, and artist tracking.
- **Fail-Safe**: Even with Spotify authorized, iTunes metadata is maintained as a redundant layer to ensure zero downtime.
### Advanced Matching ### Advanced Matching
- Unicode/accent handling (KoЯn, Björk, A$AP Rocky) - Unicode/accent handling (KoЯn, Björk, A$AP Rocky)

View file

@ -147,7 +147,7 @@ class Album:
if track_count <= 3: if track_count <= 3:
album_type = 'single' album_type = 'single'
elif track_count <= 6: elif track_count <= 6:
album_type = 'single' # iTunes calls EPs "albums" but we can mark shorter ones album_type = 'ep' # 4-6 tracks = EP
else: else:
album_type = 'album' album_type = 'album'
@ -228,7 +228,8 @@ class iTunesClient:
'country': self.country, 'country': self.country,
'media': 'music', 'media': 'music',
'entity': entity, 'entity': entity,
'limit': min(limit, 200) # iTunes max is 200 'limit': min(limit, 200), # iTunes max is 200
'explicit': 'Yes' # Include explicit content (prefer over clean versions)
} }
response = self.session.get( response = self.session.get(
@ -335,29 +336,103 @@ class iTunesClient:
@rate_limited @rate_limited
def search_albums(self, query: str, limit: int = 20) -> List[Album]: def search_albums(self, query: str, limit: int = 20) -> List[Album]:
"""Search for albums using iTunes API""" """Search for albums using iTunes API.
results = self._search(query, 'album', limit)
Filters out clean versions when explicit versions are available.
"""
results = self._search(query, 'album', limit * 2) # Fetch more to account for filtering
albums = [] albums = []
seen_albums = {} # Track albums by normalized name to prefer explicit versions
for album_data in results: for album_data in results:
if album_data.get('wrapperType') == 'collection': if album_data.get('wrapperType') != 'collection':
album = Album.from_itunes_album(album_data) continue
# Get album name and explicitness
album_name = album_data.get('collectionName', '').lower().strip()
artist_name = album_data.get('artistName', '').lower().strip()
is_explicit = album_data.get('collectionExplicitness') == 'explicit'
# Create a key for deduplication (album name + artist)
key = f"{album_name}|{artist_name}"
# If we've seen this album before
if key in seen_albums:
# Only replace if current one is explicit and previous was clean
if is_explicit and not seen_albums[key]['is_explicit']:
seen_albums[key] = {'data': album_data, 'is_explicit': is_explicit}
else:
seen_albums[key] = {'data': album_data, 'is_explicit': is_explicit}
# Convert to Album objects
for item in seen_albums.values():
album = Album.from_itunes_album(item['data'])
albums.append(album) albums.append(album)
return albums return albums[:limit]
def get_album(self, album_id: str) -> Optional[Dict[str, Any]]: def get_album(self, album_id: str, include_tracks: bool = True) -> Optional[Dict[str, Any]]:
"""Get album information""" """Get album information with tracks - normalized to Spotify format.
Args:
album_id: iTunes album/collection ID
include_tracks: If True, also fetches and includes tracks (default True for Spotify compatibility)
"""
results = self._lookup(id=album_id) results = self._lookup(id=album_id)
for album_data in results: for album_data in results:
if album_data.get('wrapperType') == 'collection': if album_data.get('wrapperType') == 'collection':
return album_data # Normalize to Spotify-compatible format
image_url = None
if album_data.get('artworkUrl100'):
image_url = album_data['artworkUrl100'].replace('100x100bb', '600x600bb')
# Build images array like Spotify (multiple sizes)
images = []
if image_url:
images = [
{'url': image_url, 'height': 600, 'width': 600},
{'url': album_data['artworkUrl100'].replace('100x100bb', '300x300bb'), 'height': 300, 'width': 300},
{'url': album_data['artworkUrl100'], 'height': 100, 'width': 100}
]
# Determine album type
track_count = album_data.get('trackCount', 0)
if track_count <= 3:
album_type = 'single'
elif track_count <= 6:
album_type = 'ep' # 4-6 tracks = EP
else:
album_type = 'album'
album_result = {
'id': str(album_data.get('collectionId', '')),
'name': album_data.get('collectionName', ''),
'images': images,
'artists': [{'name': album_data.get('artistName', 'Unknown Artist'), 'id': str(album_data.get('artistId', ''))}],
'release_date': album_data.get('releaseDate', '')[:10] if album_data.get('releaseDate') else '', # YYYY-MM-DD format
'total_tracks': track_count,
'album_type': album_type,
'external_urls': {'itunes': album_data.get('collectionViewUrl', '')},
'uri': f"itunes:album:{album_data.get('collectionId', '')}",
'_source': 'itunes',
'_raw_data': album_data
}
# Include tracks to match Spotify's get_album format
if include_tracks:
tracks_data = self.get_album_tracks(album_id)
if tracks_data and 'items' in tracks_data:
album_result['tracks'] = tracks_data
else:
album_result['tracks'] = {'items': [], 'total': 0}
return album_result
return None return None
def get_album_tracks(self, album_id: str) -> Optional[Dict[str, Any]]: def get_album_tracks(self, album_id: str) -> Optional[Dict[str, Any]]:
"""Get album tracks with all tracks included""" """Get album tracks - normalized to Spotify format"""
results = self._lookup(id=album_id, entity='song') results = self._lookup(id=album_id, entity='song')
if not results: if not results:
@ -368,10 +443,24 @@ class iTunesClient:
tracks = [] tracks = []
for item in results: for item in results:
if item.get('wrapperType') == 'track' and item.get('kind') == 'song': if item.get('wrapperType') == 'track' and item.get('kind') == 'song':
tracks.append(item) # Normalize each track to Spotify-compatible format
normalized_track = {
'id': str(item.get('trackId', '')),
'name': item.get('trackName', ''),
'artists': [{'name': item.get('artistName', 'Unknown Artist')}], # List of dicts like Spotify
'duration_ms': item.get('trackTimeMillis', 0),
'track_number': item.get('trackNumber', 0),
'disc_number': item.get('discNumber', 1),
'explicit': item.get('trackExplicitness') == 'explicit',
'preview_url': item.get('previewUrl'),
'uri': f"itunes:track:{item.get('trackId', '')}", # Synthetic URI
'external_urls': {'itunes': item.get('trackViewUrl', '')},
'_source': 'itunes'
}
tracks.append(normalized_track)
# Sort by disc and track number # Sort by disc and track number
tracks.sort(key=lambda t: (t.get('discNumber', 1), t.get('trackNumber', 0))) tracks.sort(key=lambda t: (t.get('disc_number', 1), t.get('track_number', 0)))
logger.info(f"Retrieved {len(tracks)} tracks for album {album_id}") logger.info(f"Retrieved {len(tracks)} tracks for album {album_id}")
@ -384,9 +473,31 @@ class iTunesClient:
# ==================== Artist Methods ==================== # ==================== Artist Methods ====================
def _get_artist_image_from_albums(self, artist_id: str) -> Optional[str]:
"""
Get artist image by fetching their first album's artwork.
iTunes doesn't reliably return artist images, so we use album art as fallback.
"""
try:
# Lookup is not rate-limited, so this is fast
results = self._lookup(id=artist_id, entity='album', limit=1)
for item in results:
if item.get('wrapperType') == 'collection' and item.get('artworkUrl100'):
# Return high-res version
return item['artworkUrl100'].replace('100x100bb', '600x600bb')
except Exception as e:
logger.debug(f"Could not fetch album art for artist {artist_id}: {e}")
return None
@rate_limited @rate_limited
def search_artists(self, query: str, limit: int = 20) -> List[Artist]: def search_artists(self, query: str, limit: int = 20) -> List[Artist]:
"""Search for artists using iTunes API""" """Search for artists using iTunes API.
Note: Artist images are not fetched during search to keep it fast.
Images are fetched when viewing artist details (get_artist method).
"""
results = self._search(query, 'musicArtist', limit) results = self._search(query, 'musicArtist', limit)
artists = [] artists = []
@ -399,19 +510,54 @@ class iTunesClient:
def get_artist(self, artist_id: str) -> Optional[Dict[str, Any]]: def get_artist(self, artist_id: str) -> Optional[Dict[str, Any]]:
""" """
Get full artist details from iTunes API. Get full artist details - normalized to Spotify format.
Args: Args:
artist_id: iTunes artist ID artist_id: iTunes artist ID
Returns: Returns:
Dictionary with artist data Dictionary with artist data matching Spotify's format
""" """
results = self._lookup(id=artist_id) results = self._lookup(id=artist_id)
for artist_data in results: for artist_data in results:
if artist_data.get('wrapperType') == 'artist': if artist_data.get('wrapperType') == 'artist':
return artist_data # Build images array - iTunes artist search doesn't reliably return images
# Use album art as fallback
images = []
artwork_url = artist_data.get('artworkUrl100')
# If no artist artwork, try to get from their first album
if not artwork_url:
album_art = self._get_artist_image_from_albums(str(artist_data.get('artistId', '')))
if album_art:
# Convert back to base URL format for building array
artwork_url = album_art.replace('600x600bb', '100x100bb')
if artwork_url:
images = [
{'url': artwork_url.replace('100x100bb', '600x600bb'), 'height': 600, 'width': 600},
{'url': artwork_url.replace('100x100bb', '300x300bb'), 'height': 300, 'width': 300},
{'url': artwork_url, 'height': 100, 'width': 100}
]
# Get genre
genres = []
if artist_data.get('primaryGenreName'):
genres = [artist_data['primaryGenreName']]
return {
'id': str(artist_data.get('artistId', '')),
'name': artist_data.get('artistName', ''),
'images': images,
'genres': genres,
'popularity': 0, # iTunes doesn't provide this
'followers': {'total': 0}, # iTunes doesn't provide this
'external_urls': {'itunes': artist_data.get('artistViewUrl', '')},
'uri': f"itunes:artist:{artist_data.get('artistId', '')}",
'_source': 'itunes',
'_raw_data': artist_data
}
return None return None
@ -421,23 +567,59 @@ class iTunesClient:
Note: iTunes doesn't support filtering by album_type in the same way as Spotify, Note: iTunes doesn't support filtering by album_type in the same way as Spotify,
so we fetch all albums and can filter client-side if needed. so we fetch all albums and can filter client-side if needed.
Prefers explicit versions over clean versions when both exist.
""" """
import re
results = self._lookup(id=artist_id, entity='album', limit=min(limit, 200)) results = self._lookup(id=artist_id, entity='album', limit=min(limit, 200))
albums = [] seen_albums = {} # Track albums by normalized name, prefer explicit versions
def normalize_album_name(name: str) -> str:
"""Normalize album name for deduplication (removes edition suffixes, etc.)"""
normalized = name.lower().strip()
# Remove common edition suffixes
normalized = re.sub(r'\s*[\(\[]\s*(deluxe|explicit|clean|remaster|expanded|anniversary|edition|version|bonus|special|standard).*?[\)\]]', '', normalized, flags=re.IGNORECASE)
# Remove trailing edition keywords without brackets
normalized = re.sub(r'\s*[-–—]\s*(deluxe|explicit|clean|remaster|expanded|anniversary|edition|version).*$', '', normalized, flags=re.IGNORECASE)
# Normalize whitespace
normalized = re.sub(r'\s+', ' ', normalized).strip()
return normalized
for album_data in results: for album_data in results:
if album_data.get('wrapperType') == 'collection': if album_data.get('wrapperType') != 'collection':
album = Album.from_itunes_album(album_data)
# Filter by album_type if specified
if album_type != 'album,single':
requested_types = album_type.split(',')
if album.album_type not in requested_types:
continue continue
albums.append(album) # Check if explicit
is_explicit = album_data.get('collectionExplicitness') == 'explicit'
logger.info(f"Retrieved {len(albums)} albums for artist {artist_id}") # Create album object
album = Album.from_itunes_album(album_data)
# Filter by album_type if specified (now includes 'ep')
if album_type != 'album,single':
requested_types = [t.strip() for t in album_type.split(',')]
# Also accept 'ep' when 'single' is requested (for backward compat)
if album.album_type not in requested_types:
if not (album.album_type == 'ep' and 'single' in requested_types):
continue
# Deduplicate by normalized name, prefer explicit versions
normalized_name = normalize_album_name(album.name)
if normalized_name in seen_albums:
# Only replace if current one is explicit and previous was clean
if is_explicit and not seen_albums[normalized_name]['is_explicit']:
logger.debug(f"Replacing clean version with explicit: {album.name}")
seen_albums[normalized_name] = {'album': album, 'is_explicit': is_explicit}
else:
logger.debug(f"Skipping duplicate album: {album.name} (normalized: {normalized_name})")
else:
seen_albums[normalized_name] = {'album': album, 'is_explicit': is_explicit}
# Extract albums from dict
albums = [item['album'] for item in seen_albums.values()]
logger.info(f"Retrieved {len(albums)} unique albums for artist {artist_id} (filtered from {len(results)} results)")
return albums[:limit] return albums[:limit]
# ==================== Playlist Methods ==================== # ==================== Playlist Methods ====================

View file

@ -43,7 +43,7 @@ class MetadataService:
def _log_initialization(self): def _log_initialization(self):
"""Log initialization status""" """Log initialization status"""
spotify_status = "✅ Authenticated" if self.spotify.is_authenticated() else "❌ Not authenticated" spotify_status = "✅ Authenticated" if self.spotify.is_spotify_authenticated() else "❌ Not authenticated"
itunes_status = "✅ Available" if self.itunes.is_authenticated() else "❌ Not available" itunes_status = "✅ Available" if self.itunes.is_authenticated() else "❌ Not available"
logger.info(f"MetadataService initialized - Spotify: {spotify_status}, iTunes: {itunes_status}") logger.info(f"MetadataService initialized - Spotify: {spotify_status}, iTunes: {itunes_status}")
@ -61,14 +61,16 @@ class MetadataService:
elif self.preferred_provider == "itunes": elif self.preferred_provider == "itunes":
return "itunes" return "itunes"
else: # auto else: # auto
return "spotify" if self.spotify.is_authenticated() else "itunes" # Use is_spotify_authenticated() to check actual Spotify auth status
# (is_authenticated() always returns True due to iTunes fallback)
return "spotify" if self.spotify.is_spotify_authenticated() else "itunes"
def _get_client(self): def _get_client(self):
"""Get the appropriate client based on provider selection""" """Get the appropriate client based on provider selection"""
provider = self.get_active_provider() provider = self.get_active_provider()
if provider == "spotify": if provider == "spotify":
if not self.spotify.is_authenticated(): if not self.spotify.is_spotify_authenticated():
logger.warning("Spotify requested but not authenticated, falling back to iTunes") logger.warning("Spotify requested but not authenticated, falling back to iTunes")
return self.itunes return self.itunes
return self.spotify return self.spotify
@ -168,21 +170,21 @@ class MetadataService:
def get_user_playlists(self) -> List: def get_user_playlists(self) -> List:
"""Get user playlists (Spotify only)""" """Get user playlists (Spotify only)"""
if self.get_active_provider() == "spotify" and self.spotify.is_authenticated(): if self.spotify.is_spotify_authenticated():
return self.spotify.get_user_playlists() return self.spotify.get_user_playlists()
logger.warning("User playlists only available with Spotify authentication") logger.warning("User playlists only available with Spotify authentication")
return [] return []
def get_saved_tracks(self) -> List: def get_saved_tracks(self) -> List:
"""Get user's saved/liked tracks (Spotify only)""" """Get user's saved/liked tracks (Spotify only)"""
if self.get_active_provider() == "spotify" and self.spotify.is_authenticated(): if self.spotify.is_spotify_authenticated():
return self.spotify.get_saved_tracks() return self.spotify.get_saved_tracks()
logger.warning("Saved tracks only available with Spotify authentication") logger.warning("Saved tracks only available with Spotify authentication")
return [] return []
def get_saved_tracks_count(self) -> int: def get_saved_tracks_count(self) -> int:
"""Get count of user's saved tracks (Spotify only)""" """Get count of user's saved tracks (Spotify only)"""
if self.get_active_provider() == "spotify" and self.spotify.is_authenticated(): if self.spotify.is_spotify_authenticated():
return self.spotify.get_saved_tracks_count() return self.spotify.get_saved_tracks_count()
return 0 return 0
@ -190,16 +192,16 @@ class MetadataService:
def is_authenticated(self) -> bool: def is_authenticated(self) -> bool:
"""Check if any provider is available""" """Check if any provider is available"""
return self.spotify.is_authenticated() or self.itunes.is_authenticated() return self.spotify.is_spotify_authenticated() or self.itunes.is_authenticated()
def get_provider_info(self) -> Dict[str, Any]: def get_provider_info(self) -> Dict[str, Any]:
"""Get information about available providers""" """Get information about available providers"""
return { return {
"active_provider": self.get_active_provider(), "active_provider": self.get_active_provider(),
"spotify_authenticated": self.spotify.is_authenticated(), "spotify_authenticated": self.spotify.is_spotify_authenticated(),
"itunes_available": self.itunes.is_authenticated(), "itunes_available": self.itunes.is_authenticated(),
"preferred_provider": self.preferred_provider, "preferred_provider": self.preferred_provider,
"can_access_user_data": self.spotify.is_authenticated(), "can_access_user_data": self.spotify.is_spotify_authenticated(),
} }
def reload_config(self): def reload_config(self):

View file

@ -100,6 +100,43 @@ 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."""
# Convert sqlite3.Row to dict if needed (Row objects don't support .get())
if hasattr(row, 'keys'):
row = dict(row)
track_data = row.get('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.get('track_name', 'Unknown'),
'artist_name': row.get('artist_name', 'Unknown'),
'album_name': row.get('album_name', 'Unknown'),
'album_cover_url': row.get('album_cover_url'),
'duration_ms': row.get('duration_ms', 0),
'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 +203,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 +234,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 +304,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 +366,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 +391,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 +417,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 +433,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 +504,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 +555,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 +564,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 +574,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 +602,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 +612,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 +782,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 +792,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

@ -169,8 +169,31 @@ class SpotifyClient:
def __init__(self): def __init__(self):
self.sp: Optional[spotipy.Spotify] = None self.sp: Optional[spotipy.Spotify] = None
self.user_id: Optional[str] = None self.user_id: Optional[str] = None
self._itunes_client = None # Lazy-loaded iTunes fallback
self._setup_client() self._setup_client()
def _is_spotify_id(self, id_str: str) -> bool:
"""Check if an ID is a Spotify ID (alphanumeric) vs iTunes ID (numeric only)"""
if not id_str:
return False
# Spotify IDs contain letters and numbers, iTunes IDs are purely numeric
return not id_str.isdigit()
def _is_itunes_id(self, id_str: str) -> bool:
"""Check if an ID is an iTunes ID (numeric only)"""
if not id_str:
return False
return id_str.isdigit()
@property
def _itunes(self):
"""Lazy-load iTunes client for fallback when Spotify not authenticated"""
if self._itunes_client is None:
from core.itunes_client import iTunesClient
self._itunes_client = iTunesClient()
logger.info("iTunes fallback client initialized")
return self._itunes_client
def reload_config(self): def reload_config(self):
"""Reload configuration and re-initialize client""" """Reload configuration and re-initialize client"""
self._setup_client() self._setup_client()
@ -201,7 +224,20 @@ class SpotifyClient:
self.sp = None self.sp = None
def is_authenticated(self) -> bool: def is_authenticated(self) -> bool:
"""Check if Spotify client is authenticated and working""" """
Check if client can service metadata requests.
Returns True if Spotify is authenticated OR iTunes fallback is available.
For Spotify-specific auth check, use is_spotify_authenticated().
"""
# If Spotify is authenticated, we're good
if self.is_spotify_authenticated():
return True
# iTunes fallback is always available
return True
def is_spotify_authenticated(self) -> bool:
"""Check if Spotify client is specifically authenticated (not just iTunes fallback)"""
if self.sp is None: if self.sp is None:
return False return False
@ -228,7 +264,7 @@ class SpotifyClient:
@rate_limited @rate_limited
def get_user_playlists(self) -> List[Playlist]: def get_user_playlists(self) -> List[Playlist]:
if not self.is_authenticated(): if not self.is_spotify_authenticated():
logger.error("Not authenticated with Spotify") logger.error("Not authenticated with Spotify")
return [] return []
@ -262,7 +298,7 @@ class SpotifyClient:
@rate_limited @rate_limited
def get_user_playlists_metadata_only(self) -> List[Playlist]: def get_user_playlists_metadata_only(self) -> List[Playlist]:
"""Get playlists without fetching all track details for faster loading""" """Get playlists without fetching all track details for faster loading"""
if not self.is_authenticated(): if not self.is_spotify_authenticated():
logger.error("Not authenticated with Spotify") logger.error("Not authenticated with Spotify")
return [] return []
@ -312,7 +348,7 @@ class SpotifyClient:
@rate_limited @rate_limited
def get_saved_tracks_count(self) -> int: def get_saved_tracks_count(self) -> int:
"""Get the total count of user's saved/liked songs without fetching all tracks""" """Get the total count of user's saved/liked songs without fetching all tracks"""
if not self.is_authenticated(): if not self.is_spotify_authenticated():
logger.error("Not authenticated with Spotify") logger.error("Not authenticated with Spotify")
return 0 return 0
@ -331,7 +367,7 @@ class SpotifyClient:
@rate_limited @rate_limited
def get_saved_tracks(self) -> List[Track]: def get_saved_tracks(self) -> List[Track]:
"""Fetch all user's saved/liked songs from Spotify""" """Fetch all user's saved/liked songs from Spotify"""
if not self.is_authenticated(): if not self.is_spotify_authenticated():
logger.error("Not authenticated with Spotify") logger.error("Not authenticated with Spotify")
return [] return []
@ -373,7 +409,7 @@ class SpotifyClient:
@rate_limited @rate_limited
def _get_playlist_tracks(self, playlist_id: str) -> List[Track]: def _get_playlist_tracks(self, playlist_id: str) -> List[Track]:
if not self.is_authenticated(): if not self.is_spotify_authenticated():
return [] return []
tracks = [] tracks = []
@ -397,7 +433,7 @@ class SpotifyClient:
@rate_limited @rate_limited
def get_playlist_by_id(self, playlist_id: str) -> Optional[Playlist]: def get_playlist_by_id(self, playlist_id: str) -> Optional[Playlist]:
if not self.is_authenticated(): if not self.is_spotify_authenticated():
return None return None
try: try:
@ -411,9 +447,8 @@ class SpotifyClient:
@rate_limited @rate_limited
def search_tracks(self, query: str, limit: int = 20) -> List[Track]: def search_tracks(self, query: str, limit: int = 20) -> List[Track]:
if not self.is_authenticated(): """Search for tracks - falls back to iTunes if Spotify not authenticated"""
return [] if self.is_spotify_authenticated():
try: try:
results = self.sp.search(q=query, type='track', limit=limit) results = self.sp.search(q=query, type='track', limit=limit)
tracks = [] tracks = []
@ -425,15 +460,17 @@ class SpotifyClient:
return tracks return tracks
except Exception as e: except Exception as e:
logger.error(f"Error searching tracks: {e}") logger.error(f"Error searching tracks via Spotify: {e}")
return [] # Fall through to iTunes fallback
# iTunes fallback
logger.debug(f"Using iTunes fallback for track search: {query}")
return self._itunes.search_tracks(query, limit)
@rate_limited @rate_limited
def search_artists(self, query: str, limit: int = 20) -> List[Artist]: def search_artists(self, query: str, limit: int = 20) -> List[Artist]:
"""Search for artists using Spotify API""" """Search for artists - falls back to iTunes if Spotify not authenticated"""
if not self.is_authenticated(): if self.is_spotify_authenticated():
return []
try: try:
results = self.sp.search(q=query, type='artist', limit=limit) results = self.sp.search(q=query, type='artist', limit=limit)
artists = [] artists = []
@ -445,15 +482,17 @@ class SpotifyClient:
return artists return artists
except Exception as e: except Exception as e:
logger.error(f"Error searching artists: {e}") logger.error(f"Error searching artists via Spotify: {e}")
return [] # Fall through to iTunes fallback
# iTunes fallback
logger.debug(f"Using iTunes fallback for artist search: {query}")
return self._itunes.search_artists(query, limit)
@rate_limited @rate_limited
def search_albums(self, query: str, limit: int = 20) -> List[Album]: def search_albums(self, query: str, limit: int = 20) -> List[Album]:
"""Search for albums using Spotify API""" """Search for albums - falls back to iTunes if Spotify not authenticated"""
if not self.is_authenticated(): if self.is_spotify_authenticated():
return []
try: try:
results = self.sp.search(q=query, type='album', limit=limit) results = self.sp.search(q=query, type='album', limit=limit)
albums = [] albums = []
@ -465,15 +504,17 @@ class SpotifyClient:
return albums return albums
except Exception as e: except Exception as e:
logger.error(f"Error searching albums: {e}") logger.error(f"Error searching albums via Spotify: {e}")
return [] # Fall through to iTunes fallback
# iTunes fallback
logger.debug(f"Using iTunes fallback for album search: {query}")
return self._itunes.search_albums(query, limit)
@rate_limited @rate_limited
def get_track_details(self, track_id: str) -> Optional[Dict[str, Any]]: def get_track_details(self, track_id: str) -> Optional[Dict[str, Any]]:
"""Get detailed track information including album data and track number""" """Get detailed track information - falls back to iTunes if Spotify not authenticated"""
if not self.is_authenticated(): if self.is_spotify_authenticated():
return None
try: try:
track_data = self.sp.track(track_id) track_data = self.sp.track(track_id)
@ -503,12 +544,20 @@ class SpotifyClient:
return track_data return track_data
except Exception as e: except Exception as e:
logger.error(f"Error fetching track details: {e}") logger.error(f"Error fetching track details via Spotify: {e}")
# Fall through to iTunes fallback
# iTunes fallback - only if ID is numeric (iTunes format)
if self._is_itunes_id(track_id):
logger.debug(f"Using iTunes fallback for track details: {track_id}")
return self._itunes.get_track_details(track_id)
else:
logger.debug(f"Cannot use iTunes fallback for Spotify track ID: {track_id}")
return None return None
@rate_limited @rate_limited
def get_track_features(self, track_id: str) -> Optional[Dict[str, Any]]: def get_track_features(self, track_id: str) -> Optional[Dict[str, Any]]:
if not self.is_authenticated(): if not self.is_spotify_authenticated():
return None return None
try: try:
@ -521,24 +570,28 @@ class SpotifyClient:
@rate_limited @rate_limited
def get_album(self, album_id: str) -> Optional[Dict[str, Any]]: def get_album(self, album_id: str) -> Optional[Dict[str, Any]]:
"""Get album information including tracks""" """Get album information - falls back to iTunes if Spotify not authenticated"""
if not self.is_authenticated(): if self.is_spotify_authenticated():
return None
try: try:
album_data = self.sp.album(album_id) album_data = self.sp.album(album_id)
return album_data return album_data
except Exception as e: except Exception as e:
logger.error(f"Error fetching album: {e}") logger.error(f"Error fetching album via Spotify: {e}")
# Fall through to iTunes fallback
# iTunes fallback - only if ID is numeric (iTunes format)
if self._is_itunes_id(album_id):
logger.debug(f"Using iTunes fallback for album: {album_id}")
return self._itunes.get_album(album_id)
else:
logger.debug(f"Cannot use iTunes fallback for Spotify album ID: {album_id}")
return None return None
@rate_limited @rate_limited
def get_album_tracks(self, album_id: str) -> Optional[Dict[str, Any]]: def get_album_tracks(self, album_id: str) -> Optional[Dict[str, Any]]:
"""Get album tracks with pagination to fetch all tracks""" """Get album tracks - falls back to iTunes if Spotify not authenticated"""
if not self.is_authenticated(): if self.is_spotify_authenticated():
return None
try: try:
# Get first page of tracks # Get first page of tracks
first_page = self.sp.album_tracks(album_id) first_page = self.sp.album_tracks(album_id)
@ -567,15 +620,21 @@ class SpotifyClient:
return result return result
except Exception as e: except Exception as e:
logger.error(f"Error fetching album tracks: {e}") logger.error(f"Error fetching album tracks via Spotify: {e}")
# Fall through to iTunes fallback
# iTunes fallback - only if ID is numeric (iTunes format)
if self._is_itunes_id(album_id):
logger.debug(f"Using iTunes fallback for album tracks: {album_id}")
return self._itunes.get_album_tracks(album_id)
else:
logger.debug(f"Cannot use iTunes fallback for Spotify album ID: {album_id}")
return None return None
@rate_limited @rate_limited
def get_artist_albums(self, artist_id: str, album_type: str = 'album,single', limit: int = 50) -> List[Album]: def get_artist_albums(self, artist_id: str, album_type: str = 'album,single', limit: int = 50) -> List[Album]:
"""Get albums by artist ID""" """Get albums by artist ID - falls back to iTunes if Spotify not authenticated"""
if not self.is_authenticated(): if self.is_spotify_authenticated():
return []
try: try:
albums = [] albums = []
results = self.sp.artist_albums(artist_id, album_type=album_type, limit=limit) results = self.sp.artist_albums(artist_id, album_type=album_type, limit=limit)
@ -592,12 +651,20 @@ class SpotifyClient:
return albums return albums
except Exception as e: except Exception as e:
logger.error(f"Error fetching artist albums: {e}") logger.error(f"Error fetching artist albums via Spotify: {e}")
# Fall through to iTunes fallback
# iTunes fallback - only if ID is numeric (iTunes format)
if self._is_itunes_id(artist_id):
logger.debug(f"Using iTunes fallback for artist albums: {artist_id}")
return self._itunes.get_artist_albums(artist_id, album_type, limit)
else:
logger.debug(f"Cannot use iTunes fallback for Spotify artist ID: {artist_id}")
return [] return []
@rate_limited @rate_limited
def get_user_info(self) -> Optional[Dict[str, Any]]: def get_user_info(self) -> Optional[Dict[str, Any]]:
if not self.is_authenticated(): if not self.is_spotify_authenticated():
return None return None
try: try:
@ -609,19 +676,25 @@ class SpotifyClient:
@rate_limited @rate_limited
def get_artist(self, artist_id: str) -> Optional[Dict[str, Any]]: def get_artist(self, artist_id: str) -> Optional[Dict[str, Any]]:
""" """
Get full artist details from Spotify API. Get full artist details - falls back to iTunes if Spotify not authenticated.
Args: Args:
artist_id: Spotify artist ID artist_id: Artist ID (Spotify or iTunes depending on authentication)
Returns: Returns:
Dictionary with artist data including images, genres, popularity Dictionary with artist data including images, genres, popularity
""" """
if not self.is_authenticated(): if self.is_spotify_authenticated():
return None
try: try:
return self.sp.artist(artist_id) return self.sp.artist(artist_id)
except Exception as e: except Exception as e:
logger.error(f"Error fetching artist {artist_id}: {e}") logger.error(f"Error fetching artist via Spotify: {e}")
# Fall through to iTunes fallback
# iTunes fallback - only if ID is numeric (iTunes format)
if self._is_itunes_id(artist_id):
logger.debug(f"Using iTunes fallback for artist: {artist_id}")
return self._itunes.get_artist(artist_id)
else:
logger.debug(f"Cannot use iTunes fallback for Spotify artist ID: {artist_id}")
return None return None

File diff suppressed because it is too large Load diff

View file

@ -79,13 +79,14 @@ class DatabaseTrackWithMetadata:
class WatchlistArtist: class WatchlistArtist:
"""Artist being monitored for new releases""" """Artist being monitored for new releases"""
id: int id: int
spotify_artist_id: str spotify_artist_id: Optional[str] # Can be None if added via iTunes
artist_name: str artist_name: str
date_added: datetime date_added: datetime
last_scan_timestamp: Optional[datetime] = None last_scan_timestamp: Optional[datetime] = None
created_at: Optional[datetime] = None created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None updated_at: Optional[datetime] = None
image_url: Optional[str] = None image_url: Optional[str] = None
itunes_artist_id: Optional[str] = None # Cross-provider support
include_albums: bool = True include_albums: bool = True
include_eps: bool = True include_eps: bool = True
include_singles: bool = True include_singles: bool = True
@ -96,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
@ -109,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
@ -120,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
@ -128,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]
@ -280,6 +288,12 @@ class MusicDatabase:
# Add content type filter columns to watchlist_artists (migration) # Add content type filter columns to watchlist_artists (migration)
self._add_watchlist_content_type_filters(cursor) self._add_watchlist_content_type_filters(cursor)
# Add iTunes artist ID column to watchlist_artists (migration)
self._add_watchlist_itunes_id_column(cursor)
# Make spotify_artist_id nullable for iTunes-only artists (migration)
self._fix_watchlist_spotify_id_nullable(cursor)
conn.commit() conn.commit()
logger.info("Database initialized successfully") logger.info("Database initialized successfully")
@ -446,26 +460,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,
@ -475,38 +496,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)
) )
""") """)
@ -564,22 +594,7 @@ class MusicDatabase:
) )
""") """)
# Create indexes for performance # ============== MIGRATIONS (must run BEFORE index creation on new columns) ==============
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_occurrence ON similar_artists (occurrence_count)")
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_artist ON discovery_pool (spotify_artist_id)")
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_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)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_listenbrainz_tracks_playlist ON listenbrainz_tracks (playlist_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_listenbrainz_tracks_position ON listenbrainz_tracks (playlist_id, position)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_recent_albums_artist ON discovery_recent_albums (artist_spotify_id)")
# Add genres column to discovery_pool if it doesn't exist (migration) # Add genres column to discovery_pool if it doesn't exist (migration)
cursor.execute("PRAGMA table_info(discovery_pool)") cursor.execute("PRAGMA table_info(discovery_pool)")
@ -589,6 +604,149 @@ 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")
# Migration: Fix NOT NULL constraint on album_spotify_id (required for iTunes-only albums)
# Check if album_spotify_id has NOT NULL constraint by checking table schema
cursor.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='discovery_recent_albums'")
table_schema = cursor.fetchone()
if table_schema and 'album_spotify_id TEXT NOT NULL' in (table_schema[0] or ''):
logger.info("Migrating discovery_recent_albums to allow NULL album_spotify_id for iTunes support...")
# SQLite doesn't support ALTER COLUMN, so recreate table
cursor.execute("""
CREATE TABLE IF NOT EXISTS discovery_recent_albums_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
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,
album_cover_url TEXT,
release_date TEXT NOT NULL,
album_type TEXT DEFAULT 'album',
cached_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(album_spotify_id, album_itunes_id, source)
)
""")
cursor.execute("""
INSERT OR IGNORE INTO discovery_recent_albums_new
SELECT * FROM discovery_recent_albums
""")
cursor.execute("DROP TABLE discovery_recent_albums")
cursor.execute("ALTER TABLE discovery_recent_albums_new RENAME TO discovery_recent_albums")
conn.commit()
logger.info("Successfully migrated discovery_recent_albums table for iTunes support")
# Migration: Add UNIQUE constraint to similar_artists table
# Test if ON CONFLICT works by trying a dummy operation
needs_similar_migration = False
try:
cursor.execute("""
INSERT INTO similar_artists
(source_artist_id, similar_artist_name, similarity_rank, occurrence_count, last_updated)
VALUES ('__migration_test__', '__migration_test__', 1, 1, CURRENT_TIMESTAMP)
ON CONFLICT(source_artist_id, similar_artist_name)
DO UPDATE SET occurrence_count = occurrence_count
""")
# Clean up test row
cursor.execute("DELETE FROM similar_artists WHERE source_artist_id = '__migration_test__'")
logger.info("similar_artists table has correct UNIQUE constraint")
except Exception as constraint_error:
logger.info(f"similar_artists needs migration (constraint test failed: {constraint_error})")
needs_similar_migration = True
if needs_similar_migration:
logger.info("Migrating similar_artists to add UNIQUE constraint...")
# Get a fresh connection for the migration
with self._get_connection() as migration_conn:
migration_cursor = migration_conn.cursor()
# SQLite doesn't support adding constraints, so recreate table
migration_cursor.execute("DROP TABLE IF EXISTS similar_artists_new")
migration_cursor.execute("""
CREATE TABLE similar_artists_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_artist_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_name)
)
""")
migration_cursor.execute("""
INSERT OR IGNORE INTO similar_artists_new
(source_artist_id, similar_artist_spotify_id, similar_artist_itunes_id,
similar_artist_name, similarity_rank, occurrence_count, last_updated)
SELECT source_artist_id, similar_artist_spotify_id, similar_artist_itunes_id,
similar_artist_name, similarity_rank, occurrence_count, last_updated
FROM similar_artists
""")
migration_cursor.execute("DROP TABLE similar_artists")
migration_cursor.execute("ALTER TABLE similar_artists_new RENAME TO similar_artists")
migration_conn.commit()
logger.info("Successfully migrated similar_artists table with UNIQUE constraint")
# ============== INDEXES (after migrations to ensure columns exist) ==============
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)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_listenbrainz_tracks_playlist ON listenbrainz_tracks (playlist_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_listenbrainz_tracks_position ON listenbrainz_tracks (playlist_id, position)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_recent_albums_artist ON discovery_recent_albums (artist_spotify_id)")
logger.info("Discovery tables created successfully") logger.info("Discovery tables created successfully")
except Exception as e: except Exception as e:
@ -652,6 +810,81 @@ class MusicDatabase:
logger.error(f"Error adding content type filter columns to watchlist_artists: {e}") logger.error(f"Error adding content type filter columns to watchlist_artists: {e}")
# Don't raise - this is a migration, database can still function # Don't raise - this is a migration, database can still function
def _add_watchlist_itunes_id_column(self, cursor):
"""Add iTunes artist ID column to watchlist_artists table for cross-provider support"""
try:
cursor.execute("PRAGMA table_info(watchlist_artists)")
columns = [column[1] for column in cursor.fetchall()]
if 'itunes_artist_id' not in columns:
cursor.execute("ALTER TABLE watchlist_artists ADD COLUMN itunes_artist_id TEXT")
logger.info("Added itunes_artist_id column to watchlist_artists table for cross-provider support")
except Exception as e:
logger.error(f"Error adding itunes_artist_id column to watchlist_artists: {e}")
# Don't raise - this is a migration, database can still function
def _fix_watchlist_spotify_id_nullable(self, cursor):
"""
Make spotify_artist_id nullable in watchlist_artists table.
This allows adding iTunes-only artists without Spotify IDs.
Since SQLite doesn't support modifying column constraints directly,
we need to recreate the table if the constraint needs to be changed.
"""
try:
# Check if spotify_artist_id is currently NOT NULL
cursor.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='watchlist_artists'")
result = cursor.fetchone()
if result and 'spotify_artist_id TEXT UNIQUE NOT NULL' in result[0]:
logger.info("Migrating watchlist_artists table to make spotify_artist_id nullable...")
# Create new table with nullable spotify_artist_id
cursor.execute("""
CREATE TABLE watchlist_artists_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
spotify_artist_id TEXT UNIQUE,
artist_name TEXT NOT NULL,
date_added TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_scan_timestamp TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
image_url TEXT,
include_albums INTEGER DEFAULT 1,
include_eps INTEGER DEFAULT 1,
include_singles INTEGER DEFAULT 1,
include_live INTEGER DEFAULT 0,
include_remixes INTEGER DEFAULT 0,
include_acoustic INTEGER DEFAULT 0,
include_compilations INTEGER DEFAULT 0,
itunes_artist_id TEXT
)
""")
# Copy data from old table
cursor.execute("""
INSERT INTO watchlist_artists_new
SELECT * FROM watchlist_artists
""")
# Drop old table
cursor.execute("DROP TABLE watchlist_artists")
# Rename new table
cursor.execute("ALTER TABLE watchlist_artists_new RENAME TO watchlist_artists")
# Recreate indexes
cursor.execute("CREATE INDEX IF NOT EXISTS idx_watchlist_spotify_id ON watchlist_artists (spotify_artist_id)")
logger.info("Successfully migrated watchlist_artists table - spotify_artist_id is now nullable")
else:
logger.debug("watchlist_artists table already has nullable spotify_artist_id or custom schema")
except Exception as e:
logger.error(f"Error making spotify_artist_id nullable in watchlist_artists: {e}")
# Don't raise - this is a migration, database can still function
def close(self): def close(self):
"""Close database connection (no-op since we create connections per operation)""" """Close database connection (no-op since we create connections per operation)"""
# Each operation creates and closes its own connection, so nothing to do here # Each operation creates and closes its own connection, so nothing to do here
@ -2682,64 +2915,89 @@ class MusicDatabase:
return 0 return 0
# Watchlist operations # Watchlist operations
def add_artist_to_watchlist(self, spotify_artist_id: str, artist_name: str) -> bool: def add_artist_to_watchlist(self, artist_id: str, artist_name: str) -> bool:
"""Add an artist to the watchlist for monitoring new releases""" """Add an artist to the watchlist for monitoring new releases.
Automatically detects if artist_id is a Spotify ID (alphanumeric) or iTunes ID (numeric).
"""
try: try:
with self._get_connection() as conn: with self._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
# Detect ID type: iTunes IDs are purely numeric, Spotify IDs are alphanumeric
is_itunes_id = artist_id.isdigit()
if is_itunes_id:
cursor.execute("""
INSERT OR REPLACE INTO watchlist_artists
(itunes_artist_id, artist_name, date_added, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""", (artist_id, artist_name))
logger.info(f"Added artist '{artist_name}' to watchlist (iTunes ID: {artist_id})")
else:
cursor.execute(""" cursor.execute("""
INSERT OR REPLACE INTO watchlist_artists INSERT OR REPLACE INTO watchlist_artists
(spotify_artist_id, artist_name, date_added, updated_at) (spotify_artist_id, artist_name, date_added, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""", (spotify_artist_id, artist_name)) """, (artist_id, artist_name))
logger.info(f"Added artist '{artist_name}' to watchlist (Spotify ID: {artist_id})")
conn.commit() conn.commit()
logger.info(f"Added artist '{artist_name}' to watchlist (Spotify ID: {spotify_artist_id})")
return True return True
except Exception as e: except Exception as e:
logger.error(f"Error adding artist '{artist_name}' to watchlist: {e}") logger.error(f"Error adding artist '{artist_name}' to watchlist: {e}")
return False return False
def remove_artist_from_watchlist(self, spotify_artist_id: str) -> bool: def remove_artist_from_watchlist(self, artist_id: str) -> bool:
"""Remove an artist from the watchlist""" """Remove an artist from the watchlist (checks 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()
# Get artist name for logging # Get artist name for logging (check both ID columns)
cursor.execute("SELECT artist_name FROM watchlist_artists WHERE spotify_artist_id = ?", (spotify_artist_id,)) cursor.execute("""
SELECT artist_name FROM watchlist_artists
WHERE spotify_artist_id = ? OR itunes_artist_id = ?
""", (artist_id, artist_id))
result = cursor.fetchone() result = cursor.fetchone()
artist_name = result['artist_name'] if result else "Unknown" artist_name = result['artist_name'] if result else "Unknown"
cursor.execute("DELETE FROM watchlist_artists WHERE spotify_artist_id = ?", (spotify_artist_id,)) cursor.execute("""
DELETE FROM watchlist_artists
WHERE spotify_artist_id = ? OR itunes_artist_id = ?
""", (artist_id, artist_id))
if cursor.rowcount > 0: if cursor.rowcount > 0:
conn.commit() conn.commit()
logger.info(f"Removed artist '{artist_name}' from watchlist (Spotify ID: {spotify_artist_id})") logger.info(f"Removed artist '{artist_name}' from watchlist (ID: {artist_id})")
return True return True
else: else:
logger.warning(f"Artist with Spotify ID {spotify_artist_id} not found in watchlist") logger.warning(f"Artist with ID {artist_id} not found in watchlist")
return False return False
except Exception as e: except Exception as e:
logger.error(f"Error removing artist from watchlist (Spotify ID: {spotify_artist_id}): {e}") logger.error(f"Error removing artist from watchlist (ID: {artist_id}): {e}")
return False return False
def is_artist_in_watchlist(self, spotify_artist_id: str) -> bool: def is_artist_in_watchlist(self, artist_id: str) -> bool:
"""Check if an artist is currently in the watchlist""" """Check if an artist is currently in the watchlist (checks 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()
cursor.execute("SELECT 1 FROM watchlist_artists WHERE spotify_artist_id = ? LIMIT 1", (spotify_artist_id,)) # Check both spotify_artist_id and itunes_artist_id columns
cursor.execute("""
SELECT 1 FROM watchlist_artists
WHERE spotify_artist_id = ? OR itunes_artist_id = ?
LIMIT 1
""", (artist_id, artist_id))
result = cursor.fetchone() result = cursor.fetchone()
return result is not None return result is not None
except Exception as e: except Exception as e:
logger.error(f"Error checking if artist is in watchlist (Spotify ID: {spotify_artist_id}): {e}") logger.error(f"Error checking if artist is in watchlist (ID: {artist_id}): {e}")
return False return False
def get_watchlist_artists(self) -> List[WatchlistArtist]: def get_watchlist_artists(self) -> List[WatchlistArtist]:
@ -2755,7 +3013,7 @@ class MusicDatabase:
# Build SELECT query based on existing columns # Build SELECT query based on existing columns
base_columns = ['id', 'spotify_artist_id', 'artist_name', 'date_added', base_columns = ['id', 'spotify_artist_id', 'artist_name', 'date_added',
'last_scan_timestamp', 'created_at', 'updated_at'] 'last_scan_timestamp', 'created_at', 'updated_at']
optional_columns = ['image_url', 'include_albums', 'include_eps', 'include_singles', optional_columns = ['image_url', 'itunes_artist_id', 'include_albums', 'include_eps', 'include_singles',
'include_live', 'include_remixes', 'include_acoustic', 'include_compilations'] 'include_live', 'include_remixes', 'include_acoustic', 'include_compilations']
columns_to_select = base_columns + [col for col in optional_columns if col in existing_columns] columns_to_select = base_columns + [col for col in optional_columns if col in existing_columns]
@ -2772,6 +3030,7 @@ class MusicDatabase:
for row in rows: for row in rows:
# Safely get optional columns with defaults (sqlite3.Row uses dict-style access) # Safely get optional columns with defaults (sqlite3.Row uses dict-style access)
image_url = row['image_url'] if 'image_url' in existing_columns else None image_url = row['image_url'] if 'image_url' in existing_columns else None
itunes_artist_id = row['itunes_artist_id'] if 'itunes_artist_id' in existing_columns else None
include_albums = bool(row['include_albums']) if 'include_albums' in existing_columns else True include_albums = bool(row['include_albums']) if 'include_albums' in existing_columns else True
include_eps = bool(row['include_eps']) if 'include_eps' in existing_columns else True include_eps = bool(row['include_eps']) if 'include_eps' in existing_columns else True
include_singles = bool(row['include_singles']) if 'include_singles' in existing_columns else True include_singles = bool(row['include_singles']) if 'include_singles' in existing_columns else True
@ -2789,6 +3048,7 @@ class MusicDatabase:
created_at=datetime.fromisoformat(row['created_at']) if row['created_at'] else None, created_at=datetime.fromisoformat(row['created_at']) if row['created_at'] else None,
updated_at=datetime.fromisoformat(row['updated_at']) if row['updated_at'] else None, updated_at=datetime.fromisoformat(row['updated_at']) if row['updated_at'] else None,
image_url=image_url, image_url=image_url,
itunes_artist_id=itunes_artist_id,
include_albums=include_albums, include_albums=include_albums,
include_eps=include_eps, include_eps=include_eps,
include_singles=include_singles, include_singles=include_singles,
@ -2819,8 +3079,8 @@ class MusicDatabase:
logger.error(f"Error getting watchlist count: {e}") logger.error(f"Error getting watchlist count: {e}")
return 0 return 0
def update_watchlist_artist_image(self, spotify_artist_id: str, image_url: str) -> bool: def update_watchlist_artist_image(self, artist_id: str, image_url: str) -> bool:
"""Update the image URL for a watchlist artist""" """Update the image URL for a watchlist artist (checks 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()
@ -2836,8 +3096,8 @@ class MusicDatabase:
cursor.execute(""" cursor.execute("""
UPDATE watchlist_artists UPDATE watchlist_artists
SET image_url = ?, updated_at = CURRENT_TIMESTAMP SET image_url = ?, updated_at = CURRENT_TIMESTAMP
WHERE spotify_artist_id = ? WHERE spotify_artist_id = ? OR itunes_artist_id = ?
""", (image_url, spotify_artist_id)) """, (image_url, artist_id, artist_id))
conn.commit() conn.commit()
return cursor.rowcount > 0 return cursor.rowcount > 0
@ -2846,25 +3106,91 @@ class MusicDatabase:
logger.error(f"Error updating watchlist artist image: {e}") logger.error(f"Error updating watchlist artist image: {e}")
return False return False
# === Discovery Feature Methods === def update_watchlist_spotify_id(self, watchlist_id: int, spotify_id: str) -> bool:
"""Update the Spotify artist ID for a watchlist artist (cross-provider support)"""
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"""
try: try:
with self._get_connection() as conn: with self._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("""
UPDATE watchlist_artists
SET spotify_artist_id = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""", (spotify_id, watchlist_id))
conn.commit()
logger.info(f"Updated Spotify ID for watchlist artist {watchlist_id}: {spotify_id}")
return cursor.rowcount > 0
except Exception as e:
logger.error(f"Error updating watchlist Spotify ID: {e}")
return False
def update_watchlist_itunes_id(self, watchlist_id: int, itunes_id: str) -> bool:
"""Update the iTunes artist ID for a watchlist artist (cross-provider support)"""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE watchlist_artists
SET itunes_artist_id = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""", (itunes_id, watchlist_id))
conn.commit()
logger.info(f"Updated iTunes ID for watchlist artist {watchlist_id}: {itunes_id}")
return cursor.rowcount > 0
except Exception as e:
logger.error(f"Error updating watchlist iTunes ID: {e}")
return False
def update_watchlist_artist_itunes_id(self, spotify_artist_id: str, itunes_id: str) -> bool:
"""Update the iTunes artist ID for a watchlist artist by Spotify ID (for cross-provider caching)"""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE watchlist_artists
SET itunes_artist_id = ?, updated_at = CURRENT_TIMESTAMP
WHERE spotify_artist_id = ?
""", (itunes_id, spotify_artist_id))
conn.commit()
if cursor.rowcount > 0:
logger.info(f"Cached iTunes ID {itunes_id} for Spotify artist {spotify_artist_id}")
return cursor.rowcount > 0
except Exception as e:
logger.error(f"Error caching watchlist iTunes ID: {e}")
return False
# === Discovery Feature Methods ===
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(""" 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
@ -2890,6 +3216,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'],
@ -2900,10 +3227,67 @@ class MusicDatabase:
logger.error(f"Error getting similar artists: {e}") logger.error(f"Error getting similar artists: {e}")
return [] return []
def has_fresh_similar_artists(self, source_artist_id: str, days_threshold: int = 30) -> bool: def get_similar_artists_missing_itunes_ids(self, source_artist_id: str) -> List[SimilarArtist]:
"""Get similar artists for a source that are missing iTunes IDs (for backfill)"""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT * FROM similar_artists
WHERE source_artist_id = ?
AND (similar_artist_itunes_id IS NULL OR similar_artist_itunes_id = '')
ORDER BY occurrence_count DESC
LIMIT 50
""", (source_artist_id,))
rows = cursor.fetchall()
return [SimilarArtist(
id=row['id'],
source_artist_id=row['source_artist_id'],
similar_artist_spotify_id=row['similar_artist_spotify_id'],
similar_artist_itunes_id=None,
similar_artist_name=row['similar_artist_name'],
similarity_rank=row['similarity_rank'],
occurrence_count=row['occurrence_count'],
last_updated=datetime.fromisoformat(row['last_updated'])
) for row in rows]
except Exception as e:
logger.error(f"Error getting similar artists missing iTunes IDs: {e}")
return []
def update_similar_artist_itunes_id(self, similar_artist_id: int, itunes_id: str) -> bool:
"""Update a similar artist's iTunes ID (for backfill)"""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE similar_artists
SET similar_artist_itunes_id = ?
WHERE id = ?
""", (itunes_id, similar_artist_id))
conn.commit()
return cursor.rowcount > 0
except Exception as e:
logger.error(f"Error updating similar artist iTunes ID: {e}")
return False
def has_fresh_similar_artists(self, source_artist_id: str, days_threshold: int = 30, require_itunes: bool = True, require_spotify: bool = False) -> bool:
""" """
Check if we have cached similar artists that are still fresh (<days_threshold old). Check if we have cached similar artists that are still fresh (<days_threshold old).
Returns True if we have recent data, False if data is stale or missing. Also checks that similar artists have the required provider IDs.
Args:
source_artist_id: The source artist ID to check
days_threshold: Maximum age in days to consider fresh
require_itunes: If True, also requires iTunes IDs to be present (for seamless provider switching)
require_spotify: If True, also requires Spotify IDs to be present (for Spotify discovery)
Returns True if we have recent data with required IDs, False if data is stale, missing, or incomplete.
""" """
try: try:
with self._get_connection() as conn: with self._get_connection() as conn:
@ -2925,7 +3309,44 @@ class MusicDatabase:
last_updated = datetime.fromisoformat(row['last_updated']) last_updated = datetime.fromisoformat(row['last_updated'])
days_since_update = (datetime.now() - last_updated).total_seconds() / 86400 # seconds to days days_since_update = (datetime.now() - last_updated).total_seconds() / 86400 # seconds to days
return days_since_update < days_threshold if days_since_update >= days_threshold:
return False
# Check if we have iTunes IDs (for seamless provider switching)
if require_itunes:
cursor.execute("""
SELECT COUNT(*) as total,
SUM(CASE WHEN similar_artist_itunes_id IS NOT NULL AND similar_artist_itunes_id != '' THEN 1 ELSE 0 END) as has_itunes
FROM similar_artists
WHERE source_artist_id = ?
""", (source_artist_id,))
id_row = cursor.fetchone()
if id_row and id_row['total'] > 0:
# If less than 50% have iTunes IDs, consider stale and refetch
itunes_ratio = id_row['has_itunes'] / id_row['total']
if itunes_ratio < 0.5:
logger.debug(f"Similar artists for {source_artist_id} missing iTunes IDs ({id_row['has_itunes']}/{id_row['total']}), will refetch")
return False
# Check if we have Spotify IDs (for Spotify discovery)
if require_spotify:
cursor.execute("""
SELECT COUNT(*) as total,
SUM(CASE WHEN similar_artist_spotify_id IS NOT NULL AND similar_artist_spotify_id != '' THEN 1 ELSE 0 END) as has_spotify
FROM similar_artists
WHERE source_artist_id = ?
""", (source_artist_id,))
id_row = cursor.fetchone()
if id_row and id_row['total'] > 0:
# If less than 50% have Spotify IDs, consider stale and refetch
spotify_ratio = id_row['has_spotify'] / id_row['total']
if spotify_ratio < 0.5:
logger.debug(f"Similar artists for {source_artist_id} missing Spotify IDs ({id_row['has_spotify']}/{id_row['total']}), will refetch")
return False
return True
except Exception as e: except Exception as e:
logger.error(f"Error checking similar artists freshness: {e}") logger.error(f"Error checking similar artists freshness: {e}")
@ -2941,13 +3362,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,))
@ -2957,6 +3379,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'],
@ -2967,15 +3390,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'):
cursor.execute("SELECT COUNT(*) as count FROM discovery_pool WHERE spotify_track_id = ? AND source = 'spotify'",
(track_data['spotify_track_id'],)) (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
@ -2985,14 +3417,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'],
@ -3039,32 +3476,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")
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 SELECT * FROM discovery_pool
WHERE is_new_release = 1 {where_sql}
ORDER BY added_date DESC ORDER BY added_date DESC
LIMIT ? LIMIT ?
""", (limit,)) """, params)
else:
cursor.execute("""
SELECT * FROM discovery_pool
ORDER BY added_date DESC
LIMIT ?
""", (limit,))
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'],
@ -3081,21 +3531,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')
@ -3108,12 +3562,20 @@ 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()
if source:
cursor.execute("""
SELECT * FROM discovery_recent_albums
WHERE source = ?
ORDER BY release_date DESC
LIMIT ?
""", (source, limit))
else:
cursor.execute(""" cursor.execute("""
SELECT * FROM discovery_recent_albums SELECT * FROM discovery_recent_albums
ORDER BY release_date DESC ORDER BY release_date DESC
@ -3121,14 +3583,19 @@ class MusicDatabase:
""", (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

@ -0,0 +1,270 @@
#!/usr/bin/env python3
"""
Diagnostic script to check iTunes data availability for the Discover page.
Run this script to identify issues with iTunes data population:
- Similar artists missing iTunes IDs
- Discovery pool tracks by source
- Recent albums by source
- Curated playlists status
Usage:
python tools/diagnose_itunes_discover.py
"""
import sys
import os
import json
# Add parent directory to path for imports
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from database.music_database import MusicDatabase
def diagnose_itunes_discover():
"""Run diagnostic checks for iTunes discover data."""
print("=" * 60)
print("iTunes Discover Page Diagnostic Report")
print("=" * 60)
db = MusicDatabase()
# 1. Check Similar Artists
print("\n[1] SIMILAR ARTISTS")
print("-" * 40)
try:
with db._get_connection() as conn:
cursor = conn.cursor()
# Total similar artists
cursor.execute("SELECT COUNT(*) as total FROM similar_artists")
total = cursor.fetchone()['total']
# With iTunes IDs
cursor.execute("SELECT COUNT(*) as count FROM similar_artists WHERE similar_artist_itunes_id IS NOT NULL")
with_itunes = cursor.fetchone()['count']
# With Spotify IDs
cursor.execute("SELECT COUNT(*) as count FROM similar_artists WHERE similar_artist_spotify_id IS NOT NULL")
with_spotify = cursor.fetchone()['count']
# With both
cursor.execute("""
SELECT COUNT(*) as count FROM similar_artists
WHERE similar_artist_itunes_id IS NOT NULL
AND similar_artist_spotify_id IS NOT NULL
""")
with_both = cursor.fetchone()['count']
print(f" Total similar artists: {total}")
print(f" With iTunes ID: {with_itunes} ({100*with_itunes/total:.1f}%)" if total > 0 else " With iTunes ID: 0")
print(f" With Spotify ID: {with_spotify} ({100*with_spotify/total:.1f}%)" if total > 0 else " With Spotify ID: 0")
print(f" With BOTH IDs: {with_both} ({100*with_both/total:.1f}%)" if total > 0 else " With BOTH IDs: 0")
if with_itunes == 0 and total > 0:
print(" [CRITICAL] No similar artists have iTunes IDs - Hero section will be empty!")
elif with_itunes < total * 0.5:
print(" [WARNING] Less than 50% of similar artists have iTunes IDs")
else:
print(" [OK] iTunes coverage is adequate")
except Exception as e:
print(f" [ERROR] Could not check similar artists: {e}")
# 2. Check Discovery Pool
print("\n[2] DISCOVERY POOL")
print("-" * 40)
try:
with db._get_connection() as conn:
cursor = conn.cursor()
# Total tracks
cursor.execute("SELECT COUNT(*) as total FROM discovery_pool")
total = cursor.fetchone()['total']
# By source
cursor.execute("""
SELECT source, COUNT(*) as count
FROM discovery_pool
GROUP BY source
""")
source_counts = {row['source']: row['count'] for row in cursor.fetchall()}
print(f" Total tracks: {total}")
print(f" Spotify tracks: {source_counts.get('spotify', 0)}")
print(f" iTunes tracks: {source_counts.get('itunes', 0)}")
if source_counts.get('itunes', 0) == 0 and total > 0:
print(" [CRITICAL] No iTunes tracks in discovery pool - Fresh Tape/Archives will be empty!")
elif source_counts.get('itunes', 0) < total * 0.3:
print(" [WARNING] Low iTunes track count in discovery pool")
else:
print(" [OK] iTunes tracks present")
except Exception as e:
print(f" [ERROR] Could not check discovery pool: {e}")
# 3. Check Recent Albums
print("\n[3] RECENT ALBUMS CACHE")
print("-" * 40)
try:
with db._get_connection() as conn:
cursor = conn.cursor()
# Total albums
cursor.execute("SELECT COUNT(*) as total FROM discovery_recent_albums")
total = cursor.fetchone()['total']
# By source
cursor.execute("""
SELECT source, COUNT(*) as count
FROM discovery_recent_albums
GROUP BY source
""")
source_counts = {row['source']: row['count'] for row in cursor.fetchall()}
print(f" Total recent albums: {total}")
print(f" Spotify albums: {source_counts.get('spotify', 0)}")
print(f" iTunes albums: {source_counts.get('itunes', 0)}")
if source_counts.get('itunes', 0) == 0 and total > 0:
print(" [CRITICAL] No iTunes albums cached - Recent Releases section will be empty!")
elif source_counts.get('itunes', 0) < 5:
print(" [WARNING] Very few iTunes albums cached")
else:
print(" [OK] iTunes albums cached")
except Exception as e:
print(f" [ERROR] Could not check recent albums: {e}")
# 4. Check Curated Playlists
print("\n[4] CURATED PLAYLISTS")
print("-" * 40)
try:
with db._get_connection() as conn:
cursor = conn.cursor()
playlists_to_check = [
'release_radar',
'release_radar_spotify',
'release_radar_itunes',
'discovery_weekly',
'discovery_weekly_spotify',
'discovery_weekly_itunes'
]
for playlist_type in playlists_to_check:
cursor.execute("""
SELECT track_ids_json FROM discovery_curated_playlists
WHERE playlist_type = ?
""", (playlist_type,))
row = cursor.fetchone()
if row:
track_ids = json.loads(row['track_ids_json'])
status = f"{len(track_ids)} tracks"
if len(track_ids) == 0:
status += " [EMPTY]"
else:
status = "[NOT FOUND]"
print(f" {playlist_type}: {status}")
# Check iTunes-specific playlists
cursor.execute("""
SELECT track_ids_json FROM discovery_curated_playlists
WHERE playlist_type = 'release_radar_itunes'
""")
itunes_rr = cursor.fetchone()
cursor.execute("""
SELECT track_ids_json FROM discovery_curated_playlists
WHERE playlist_type = 'discovery_weekly_itunes'
""")
itunes_dw = cursor.fetchone()
if not itunes_rr or len(json.loads(itunes_rr['track_ids_json'])) == 0:
print("\n [CRITICAL] release_radar_itunes is empty or missing!")
if not itunes_dw or len(json.loads(itunes_dw['track_ids_json'])) == 0:
print(" [CRITICAL] discovery_weekly_itunes is empty or missing!")
except Exception as e:
print(f" [ERROR] Could not check curated playlists: {e}")
# 5. Check Watchlist Artists
print("\n[5] WATCHLIST ARTISTS")
print("-" * 40)
try:
with db._get_connection() as conn:
cursor = conn.cursor()
# Total artists
cursor.execute("SELECT COUNT(*) as total FROM watchlist_artists")
total = cursor.fetchone()['total']
# With iTunes IDs
cursor.execute("SELECT COUNT(*) as count FROM watchlist_artists WHERE itunes_artist_id IS NOT NULL")
with_itunes = cursor.fetchone()['count']
# With Spotify IDs
cursor.execute("SELECT COUNT(*) as count FROM watchlist_artists WHERE spotify_artist_id IS NOT NULL")
with_spotify = cursor.fetchone()['count']
print(f" Total watchlist artists: {total}")
print(f" With iTunes ID: {with_itunes} ({100*with_itunes/total:.1f}%)" if total > 0 else " With iTunes ID: 0")
print(f" With Spotify ID: {with_spotify} ({100*with_spotify/total:.1f}%)" if total > 0 else " With Spotify ID: 0")
if with_itunes == 0 and total > 0:
print(" [WARNING] No watchlist artists have iTunes IDs - source artist data limited")
except Exception as e:
print(f" [ERROR] Could not check watchlist artists: {e}")
# Summary
print("\n" + "=" * 60)
print("SUMMARY & RECOMMENDED ACTIONS")
print("=" * 60)
print("""
If you see [CRITICAL] or [WARNING] messages above, follow these steps:
QUICK FIX - Force Refresh Discover Data:
-----------------------------------------
Call the API endpoint to refresh discover data:
curl -X POST http://localhost:5000/api/discover/refresh
This will:
- Cache recent albums from your watchlist artists
- Create curated playlists (Release Radar & Discovery Weekly)
FULL FIX - Run Watchlist Scan:
------------------------------
1. Go to the web UI Settings page
2. Click "Scan Watchlist" button
3. Wait for scan to complete
This will:
- Fetch similar artists from MusicMap for each watchlist artist
- Populate the discovery pool with tracks
- Cache recent albums
- Create curated playlists
ROOT CAUSE NOTES:
-----------------
- Similar artists = 0: MusicMap fetch may have failed. Watchlist scan needed.
- Recent albums = 0: cache_discovery_recent_albums() needs to run.
- Curated playlists missing: curate_discovery_playlists() needs to run.
The discover page will now fall back to watchlist artists if similar
artists are not available, so basic functionality should still work.
""")
if __name__ == '__main__':
diagnose_itunes_discover()

File diff suppressed because it is too large Load diff

View file

@ -131,7 +131,7 @@
<!-- Version Section --> <!-- Version Section -->
<div class="version-section"> <div class="version-section">
<button class="version-button" onclick="showVersionInfo()">v1.3</button> <button class="version-button" onclick="showVersionInfo()">v1.4</button>
</div> </div>
<!-- Status Section --> <!-- Status Section -->
@ -139,7 +139,7 @@
<h4 class="status-title">Service Status</h4> <h4 class="status-title">Service Status</h4>
<div class="status-indicator" id="spotify-indicator"> <div class="status-indicator" id="spotify-indicator">
<span class="status-dot disconnected"></span> <span class="status-dot disconnected"></span>
<span class="status-name">Spotify</span> <span class="status-name" id="music-source-name">Spotify</span>
</div> </div>
<div class="status-indicator" id="media-server-indicator"> <div class="status-indicator" id="media-server-indicator">
<span class="status-dot disconnected"></span> <span class="status-dot disconnected"></span>
@ -175,7 +175,7 @@
<div class="service-status-grid"> <div class="service-status-grid">
<div class="service-card" id="spotify-service-card"> <div class="service-card" id="spotify-service-card">
<div class="service-card-header"> <div class="service-card-header">
<span class="service-card-title">Spotify</span> <span class="service-card-title" id="music-source-title">Spotify</span>
<span class="service-card-indicator disconnected" <span class="service-card-indicator disconnected"
id="spotify-status-indicator">●</span> id="spotify-status-indicator">●</span>
</div> </div>
@ -3305,7 +3305,8 @@
<div class="config-section"> <div class="config-section">
<h3 class="config-section-title">Content Filters</h3> <h3 class="config-section-title">Content Filters</h3>
<p class="config-section-subtitle">Check to INCLUDE, leave unchecked to EXCLUDE (default: all excluded)</p> <p class="config-section-subtitle">Check to INCLUDE, leave unchecked to EXCLUDE (default: all
excluded)</p>
<div class="config-options"> <div class="config-options">
<label class="config-option"> <label class="config-option">
@ -3314,7 +3315,8 @@
<div class="config-option-icon">🎤</div> <div class="config-option-icon">🎤</div>
<div class="config-option-text"> <div class="config-option-text">
<span class="config-option-title">Include Live Versions</span> <span class="config-option-title">Include Live Versions</span>
<span class="config-option-description">Check to include live performances and concerts</span> <span class="config-option-description">Check to include live performances and
concerts</span>
</div> </div>
</div> </div>
</label> </label>
@ -3325,7 +3327,8 @@
<div class="config-option-icon">🎧</div> <div class="config-option-icon">🎧</div>
<div class="config-option-text"> <div class="config-option-text">
<span class="config-option-title">Include Remixes</span> <span class="config-option-title">Include Remixes</span>
<span class="config-option-description">Check to include remix versions and edits</span> <span class="config-option-description">Check to include remix versions and
edits</span>
</div> </div>
</div> </div>
</label> </label>
@ -3336,7 +3339,8 @@
<div class="config-option-icon">🎸</div> <div class="config-option-icon">🎸</div>
<div class="config-option-text"> <div class="config-option-text">
<span class="config-option-title">Include Acoustic Versions</span> <span class="config-option-title">Include Acoustic Versions</span>
<span class="config-option-description">Check to include acoustic and stripped versions</span> <span class="config-option-description">Check to include acoustic and stripped
versions</span>
</div> </div>
</div> </div>
</label> </label>
@ -3347,7 +3351,8 @@
<div class="config-option-icon">📀</div> <div class="config-option-icon">📀</div>
<div class="config-option-text"> <div class="config-option-text">
<span class="config-option-title">Include Compilations</span> <span class="config-option-title">Include Compilations</span>
<span class="config-option-description">Check to include greatest hits and collections</span> <span class="config-option-description">Check to include greatest hits and
collections</span>
</div> </div>
</div> </div>
</label> </label>

View file

@ -12,6 +12,7 @@ let currentStream = {
progress: 0, progress: 0,
track: null track: null
}; };
let currentMusicSourceName = 'Spotify'; // 'Spotify' or 'Apple Music' - updated from status endpoint
// Streaming state management (enhanced functionality) // Streaming state management (enhanced functionality)
let streamStatusPoller = null; let streamStatusPoller = null;
@ -2164,7 +2165,8 @@ async function testConnection(service) {
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
showToast(`${service} connection successful`, 'success'); // Use backend's message which contains dynamic source name (Spotify or Apple Music)
showToast(result.message || `${service} connection successful`, 'success');
// Load music libraries after successful connection // Load music libraries after successful connection
if (service === 'plex') { if (service === 'plex') {
@ -2197,7 +2199,8 @@ async function testDashboardConnection(service) {
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
showToast(`${service} service verified`, 'success'); // Use backend's message which contains dynamic source name (Spotify or Apple Music)
showToast(result.message || `${service} service verified`, 'success');
} else { } else {
showToast(`${service} service check failed: ${result.error}`, 'error'); showToast(`${service} service check failed: ${result.error}`, 'error');
} }
@ -2681,6 +2684,57 @@ function initializeSearchModeToggle() {
}; };
} }
); );
// Lazy load artist images that are missing
lazyLoadEnhancedSearchArtistImages();
}
// Lazy load artist images for enhanced search results
async function lazyLoadEnhancedSearchArtistImages() {
const artistLists = [
document.getElementById('enh-db-artists-list'),
document.getElementById('enh-spotify-artists-list')
];
for (const list of artistLists) {
if (!list) continue;
const cardsNeedingImages = list.querySelectorAll('[data-needs-image="true"]');
if (cardsNeedingImages.length === 0) continue;
console.log(`🖼️ Lazy loading ${cardsNeedingImages.length} artist images in enhanced search`);
for (const card of cardsNeedingImages) {
const artistId = card.dataset.artistId;
if (!artistId) continue;
try {
const response = await fetch(`/api/artist/${artistId}/image`);
const data = await response.json();
if (data.success && data.image_url) {
// Find the placeholder and replace with image
const placeholder = card.querySelector('.enh-item-image-placeholder');
if (placeholder) {
const img = document.createElement('img');
img.src = data.image_url;
img.className = 'enh-item-image artist-image';
img.alt = card.querySelector('.enh-item-name')?.textContent || 'Artist';
placeholder.replaceWith(img);
// Apply dynamic glow
extractImageColors(data.image_url, (colors) => {
applyDynamicGlow(card, colors);
});
}
card.dataset.needsImage = 'false';
console.log(`✅ Loaded image for artist ${artistId}`);
}
} catch (error) {
console.warn(`⚠️ Failed to load image for artist ${artistId}:`, error);
}
}
}
} }
function formatDuration(durationMs) { function formatDuration(durationMs) {
@ -2729,6 +2783,11 @@ function initializeSearchModeToggle() {
// Add appropriate card class // Add appropriate card class
if (isArtist) { if (isArtist) {
elem.className = 'enh-compact-item artist-card'; elem.className = 'enh-compact-item artist-card';
// Add data attributes for lazy loading
if (item.id) {
elem.dataset.artistId = item.id;
elem.dataset.needsImage = config.image ? 'false' : 'true';
}
} else if (isAlbum) { } else if (isAlbum) {
elem.className = 'enh-compact-item album-card'; elem.className = 'enh-compact-item album-card';
} else if (isTrack) { } else if (isTrack) {
@ -2752,7 +2811,7 @@ function initializeSearchModeToggle() {
const imageHtml = config.image const imageHtml = config.image
? `<img src="${escapeHtml(config.image)}" class="${imageClass}" alt="${escapeHtml(config.name)}">` ? `<img src="${escapeHtml(config.image)}" class="${imageClass}" alt="${escapeHtml(config.name)}">`
: `<div class="${placeholderClass}">${config.placeholder}</div>`; : `<div class="${placeholderClass}" data-lazy-image="true">${config.placeholder}</div>`;
const badgeHtml = config.badge const badgeHtml = config.badge
? `<div class="enh-item-badge ${config.badge.class}">${config.badge.text}</div>` ? `<div class="enh-item-badge ${config.badge.class}">${config.badge.text}</div>`
@ -9890,7 +9949,8 @@ function openDiscoveryFixModal(platform, identifier, trackIndex) {
// Note: Beatport, Tidal, and ListenBrainz have their own states, but reuse YouTube modal infrastructure // Note: Beatport, Tidal, and ListenBrainz have their own states, but reuse YouTube modal infrastructure
let state, result; let state, result;
if (platform === 'youtube') { if (platform === 'youtube') {
state = youtubePlaylistStates[identifier]; // Check both states - ListenBrainz also uses YouTube modal infrastructure
state = listenbrainzPlaylistStates[identifier] || youtubePlaylistStates[identifier];
} else if (platform === 'tidal') { } else if (platform === 'tidal') {
state = youtubePlaylistStates[identifier]; // Tidal uses YouTube state infrastructure state = youtubePlaylistStates[identifier]; // Tidal uses YouTube state infrastructure
} else if (platform === 'beatport') { } else if (platform === 'beatport') {
@ -10043,15 +10103,23 @@ async function searchDiscoveryFix() {
return; return;
} }
// Determine discovery source from state
const identifier = currentDiscoveryFix.identifier;
const state = listenbrainzPlaylistStates[identifier] || youtubePlaylistStates[identifier];
const discoverySource = state?.discovery_source || state?.discoverySource || 'spotify';
const useItunes = discoverySource === 'itunes';
const resultsContainer = fixModalOverlay.querySelector('#fix-modal-results'); const resultsContainer = fixModalOverlay.querySelector('#fix-modal-results');
resultsContainer.innerHTML = '<div class="loading">🔍 Searching Spotify...</div>'; const sourceLabel = useItunes ? 'iTunes' : 'Spotify';
resultsContainer.innerHTML = `<div class="loading">🔍 Searching ${sourceLabel}...</div>`;
try { try {
// Build search query // Build search query
const query = `${artistInput} ${trackInput}`.trim(); const query = `${artistInput} ${trackInput}`.trim();
// Call Spotify search API // Call appropriate search API based on discovery source
const response = await fetch(`/api/spotify/search_tracks?query=${encodeURIComponent(query)}&limit=20`); const searchEndpoint = useItunes ? '/api/itunes/search_tracks' : '/api/spotify/search_tracks';
const response = await fetch(`${searchEndpoint}?query=${encodeURIComponent(query)}&limit=20`);
const data = await response.json(); const data = await response.json();
if (data.error) { if (data.error) {
@ -10064,7 +10132,7 @@ async function searchDiscoveryFix() {
return; return;
} }
// Render results // Render results (same format for both Spotify and iTunes)
renderDiscoveryFixResults(data.tracks, fixModalOverlay); renderDiscoveryFixResults(data.tracks, fixModalOverlay);
} catch (error) { } catch (error) {
@ -10160,13 +10228,16 @@ async function selectDiscoveryFixTrack(track) {
// Update frontend state // Update frontend state
// Note: Beatport and Tidal reuse youtubePlaylistStates for discovery results // Note: Beatport and Tidal reuse youtubePlaylistStates for discovery results
// ListenBrainz uses its own state but may also be accessed via YouTube
let state; let state;
if (platform === 'youtube') { if (platform === 'youtube') {
state = youtubePlaylistStates[identifier]; state = listenbrainzPlaylistStates[identifier] || youtubePlaylistStates[identifier];
} else if (platform === 'tidal') { } else if (platform === 'tidal') {
state = youtubePlaylistStates[identifier]; state = youtubePlaylistStates[identifier];
} else if (platform === 'beatport') { } else if (platform === 'beatport') {
state = youtubePlaylistStates[identifier]; state = youtubePlaylistStates[identifier];
} else if (platform === 'listenbrainz') {
state = listenbrainzPlaylistStates[identifier];
} }
// Support both camelCase and snake_case // Support both camelCase and snake_case
@ -11451,6 +11522,10 @@ function createArtistCard(artist, confidence) {
const imageUrl = artist.image_url || ''; const imageUrl = artist.image_url || '';
const confidencePercent = Math.round(confidence * 100); const confidencePercent = Math.round(confidence * 100);
// Add data attribute for lazy loading
card.dataset.artistId = artist.id;
card.dataset.needsImage = imageUrl ? 'false' : 'true';
card.innerHTML = ` card.innerHTML = `
<div class="suggestion-card-overlay"></div> <div class="suggestion-card-overlay"></div>
<div class="suggestion-card-content"> <div class="suggestion-card-content">
@ -11683,6 +11758,16 @@ function renderArtistSearchResults(results) {
console.error(`Error calling createArtistCard for result ${index}:`, error); console.error(`Error calling createArtistCard for result ${index}:`, error);
} }
}); });
// Lazy load missing artist images
console.log('🖼️ Starting lazy load for artist images in matching modal...');
if (typeof lazyLoadArtistImages === 'function') {
lazyLoadArtistImages(container);
} else if (typeof window.lazyLoadArtistImages === 'function') {
window.lazyLoadArtistImages(container);
} else {
console.error('❌ lazyLoadArtistImages function not found!');
}
} }
function renderAlbumSearchResults(results) { function renderAlbumSearchResults(results) {
@ -18053,7 +18138,7 @@ function openYouTubeDiscoveryModal(urlHash) {
<div class="modal-body"> <div class="modal-body">
<div class="progress-section"> <div class="progress-section">
<div class="progress-label">🔍 Spotify Discovery Progress</div> <div class="progress-label">🔍 ${currentMusicSourceName} Discovery Progress</div>
<div class="progress-bar-container"> <div class="progress-bar-container">
<div class="progress-bar-fill" id="youtube-discovery-progress-${urlHash}" style="width: 0%;"></div> <div class="progress-bar-fill" id="youtube-discovery-progress-${urlHash}" style="width: 0%;"></div>
</div> </div>
@ -18067,8 +18152,8 @@ function openYouTubeDiscoveryModal(urlHash) {
<th>${sourceLabel} Track</th> <th>${sourceLabel} Track</th>
<th>${sourceLabel} Artist</th> <th>${sourceLabel} Artist</th>
<th>Status</th> <th>Status</th>
<th>Spotify Track</th> <th>${currentMusicSourceName} Track</th>
<th>Spotify Artist</th> <th>${currentMusicSourceName} Artist</th>
<th>Album</th> <th>Album</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
@ -18216,7 +18301,7 @@ function getModalActionButtons(urlHash, phase, state = null) {
} }
} else { } else {
// Discovering phase - show progress // Discovering phase - show progress
return `<div class="modal-info">🔍 Discovering Spotify matches...</div>`; return `<div class="modal-info">🔍 Discovering ${currentMusicSourceName} matches...</div>`;
} }
case 'discovered': case 'discovered':
@ -18362,13 +18447,13 @@ function getModalDescription(phase, isTidal = false, isBeatport = false, isListe
const source = isListenBrainz ? 'ListenBrainz' : (isBeatport ? 'Beatport' : (isTidal ? 'Tidal' : 'YouTube')); const source = isListenBrainz ? 'ListenBrainz' : (isBeatport ? 'Beatport' : (isTidal ? 'Tidal' : 'YouTube'));
switch (phase) { switch (phase) {
case 'fresh': case 'fresh':
return `Ready to discover clean Spotify metadata for ${source} tracks...`; return `Ready to discover clean ${currentMusicSourceName} metadata for ${source} tracks...`;
case 'discovering': case 'discovering':
return `Discovering clean Spotify metadata for ${source} tracks...`; return `Discovering clean ${currentMusicSourceName} metadata for ${source} tracks...`;
case 'discovered': case 'discovered':
return 'Discovery complete! View the results below.'; return 'Discovery complete! View the results below.';
default: default:
return `Discovering clean Spotify metadata for ${source} tracks...`; return `Discovering clean ${currentMusicSourceName} metadata for ${source} tracks...`;
} }
} }
@ -18532,15 +18617,17 @@ function updateYouTubeDiscoveryModal(urlHash, status) {
// Update actions cell with appropriate button // Update actions cell with appropriate button
if (actionsCell) { if (actionsCell) {
const state = youtubePlaylistStates[urlHash]; const state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash];
const platform = state?.is_tidal_playlist ? 'tidal' : (state?.is_beatport_playlist ? 'beatport' : 'youtube'); const platform = state?.is_listenbrainz_playlist ? 'listenbrainz' :
(state?.is_tidal_playlist ? 'tidal' :
(state?.is_beatport_playlist ? 'beatport' : 'youtube'));
actionsCell.innerHTML = generateDiscoveryActionButton(result, urlHash, platform); actionsCell.innerHTML = generateDiscoveryActionButton(result, urlHash, platform);
} }
}); });
// Update action buttons if discovery is complete (progress = 100%) // Update action buttons if discovery is complete (progress = 100%)
if (status.progress >= 100) { if (status.progress >= 100) {
const state = youtubePlaylistStates[urlHash]; const state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash];
if (state && state.phase === 'discovered') { if (state && state.phase === 'discovered') {
const actionButtonsContainer = document.querySelector(`#youtube-discovery-modal-${urlHash} .modal-footer-left`); const actionButtonsContainer = document.querySelector(`#youtube-discovery-modal-${urlHash} .modal-footer-left`);
if (actionButtonsContainer) { if (actionButtonsContainer) {
@ -19768,6 +19855,16 @@ function displayArtistsResults(query, results) {
// Update watchlist status for all cards // Update watchlist status for all cards
updateArtistCardWatchlistStatus(); updateArtistCardWatchlistStatus();
// Lazy load missing artist images
console.log('🖼️ Starting lazy load for artist images on Artists page...');
if (typeof lazyLoadArtistImages === 'function') {
lazyLoadArtistImages(container);
} else if (typeof window.lazyLoadArtistImages === 'function') {
window.lazyLoadArtistImages(container);
} else {
console.error('❌ lazyLoadArtistImages function not found!');
}
// Add mouse wheel horizontal scrolling // Add mouse wheel horizontal scrolling
container.addEventListener('wheel', (event) => { container.addEventListener('wheel', (event) => {
if (event.deltaY !== 0) { if (event.deltaY !== 0) {
@ -19777,6 +19874,77 @@ function displayArtistsResults(query, results) {
}); });
} }
/**
* Lazy load artist images for cards that don't have images yet.
* Fetches images asynchronously so search results appear immediately.
*/
async function lazyLoadArtistImages(container) {
if (!container) {
console.error('❌ lazyLoadArtistImages: container is null');
return;
}
// Find all cards that need images
const cardsNeedingImages = container.querySelectorAll('[data-needs-image="true"]');
if (cardsNeedingImages.length === 0) {
console.log('✅ All artist cards have images');
return;
}
console.log(`🖼️ Lazy loading images for ${cardsNeedingImages.length} artist cards`);
// Load images in parallel (but with a small batch to avoid overwhelming the server)
const batchSize = 5;
const cards = Array.from(cardsNeedingImages);
for (let i = 0; i < cards.length; i += batchSize) {
const batch = cards.slice(i, i + batchSize);
await Promise.all(batch.map(async (card) => {
const artistId = card.dataset.artistId;
if (!artistId) {
console.warn('⚠️ Card missing artistId:', card);
return;
}
try {
console.log(`🔄 Fetching image for artist ${artistId}...`);
const response = await fetch(`/api/artist/${artistId}/image`);
const data = await response.json();
console.log(`📥 Got response for ${artistId}:`, data);
if (data.success && data.image_url) {
// Update the card's background image
// Handle both card types (suggestion-card and artist-card)
if (card.classList.contains('suggestion-card')) {
card.style.backgroundImage = `url(${data.image_url})`;
card.style.backgroundSize = 'cover';
card.style.backgroundPosition = 'center';
} else if (card.classList.contains('artist-card')) {
const bgElement = card.querySelector('.artist-card-background');
if (bgElement) {
// Clear the gradient first, then set the image
bgElement.style.cssText = `background-image: url('${data.image_url}'); background-size: cover; background-position: center;`;
}
}
card.dataset.needsImage = 'false';
console.log(`✅ Loaded image for artist ${artistId}`);
}
} catch (error) {
console.error(`❌ Failed to load image for artist ${artistId}:`, error);
}
}));
}
console.log('✅ Finished lazy loading artist images');
}
// Make function globally accessible
window.lazyLoadArtistImages = lazyLoadArtistImages;
/** /**
* Create HTML for an artist card * Create HTML for an artist card
*/ */
@ -19794,8 +19962,11 @@ function createArtistCardHTML(artist) {
// Format popularity as a percentage for better UX // Format popularity as a percentage for better UX
const popularityText = popularity > 0 ? `${popularity}% Popular` : 'Popularity Unknown'; const popularityText = popularity > 0 ? `${popularity}% Popular` : 'Popularity Unknown';
// Track if image needs to be lazy loaded
const needsImage = imageUrl ? 'false' : 'true';
return ` return `
<div class="artist-card" data-artist-id="${artist.id}"> <div class="artist-card" data-artist-id="${artist.id}" data-needs-image="${needsImage}">
<div class="artist-card-background" style="${backgroundStyle}"></div> <div class="artist-card-background" style="${backgroundStyle}"></div>
<div class="artist-card-overlay"></div> <div class="artist-card-overlay"></div>
<div class="artist-card-content"> <div class="artist-card-content">
@ -19846,15 +20017,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]) {
@ -19876,8 +20049,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) {
@ -20107,6 +20286,9 @@ async function loadSimilarArtists(artistName) {
<div style="font-size: 14px;">No similar artists found</div> <div style="font-size: 14px;">No similar artists found</div>
</div> </div>
`; `;
} else {
// Lazy load images for similar artists that don't have them
lazyLoadSimilarArtistImages(container);
} }
} }
} catch (parseError) { } catch (parseError) {
@ -20145,6 +20327,54 @@ async function loadSimilarArtists(artistName) {
} }
} }
/**
* Lazy load images for similar artist bubbles that don't have images
*/
async function lazyLoadSimilarArtistImages(container) {
if (!container) return;
const bubblesNeedingImages = container.querySelectorAll('.similar-artist-bubble[data-needs-image="true"]');
if (bubblesNeedingImages.length === 0) {
console.log('✅ All similar artist bubbles have images');
return;
}
console.log(`🖼️ Lazy loading images for ${bubblesNeedingImages.length} similar artists`);
// Load images in parallel batches
const batchSize = 5;
const bubbles = Array.from(bubblesNeedingImages);
for (let i = 0; i < bubbles.length; i += batchSize) {
const batch = bubbles.slice(i, i + batchSize);
await Promise.all(batch.map(async (bubble) => {
const artistId = bubble.getAttribute('data-artist-id');
if (!artistId) return;
try {
const response = await fetch(`/api/artist/${artistId}/image`);
const data = await response.json();
if (data.success && data.image_url) {
const imageContainer = bubble.querySelector('.similar-artist-bubble-image');
if (imageContainer) {
const artistName = bubble.querySelector('.similar-artist-bubble-name')?.textContent || 'Artist';
imageContainer.innerHTML = `<img src="${data.image_url}" alt="${artistName}">`;
bubble.setAttribute('data-needs-image', 'false');
console.log(`✅ Loaded image for similar artist ${artistId}`);
}
}
} catch (error) {
console.warn(`⚠️ Failed to load image for similar artist ${artistId}:`, error);
}
}));
}
console.log('✅ Finished lazy loading similar artist images');
}
/** /**
* Display similar artist bubble cards progressively (one at a time with delay) * Display similar artist bubble cards progressively (one at a time with delay)
*/ */
@ -20206,11 +20436,15 @@ function createSimilarArtistBubble(artist) {
bubble.className = 'similar-artist-bubble'; bubble.className = 'similar-artist-bubble';
bubble.setAttribute('data-artist-id', artist.id); bubble.setAttribute('data-artist-id', artist.id);
// Track if image needs lazy loading
const hasImage = artist.image_url && artist.image_url.trim() !== '';
bubble.setAttribute('data-needs-image', hasImage ? 'false' : 'true');
// Create image container // Create image container
const imageContainer = document.createElement('div'); const imageContainer = document.createElement('div');
imageContainer.className = 'similar-artist-bubble-image'; imageContainer.className = 'similar-artist-bubble-image';
if (artist.image_url && artist.image_url.trim() !== '') { if (hasImage) {
const img = document.createElement('img'); const img = document.createElement('img');
img.src = artist.image_url; img.src = artist.image_url;
img.alt = artist.name; img.alt = artist.name;
@ -20219,11 +20453,12 @@ function createSimilarArtistBubble(artist) {
img.onerror = () => { img.onerror = () => {
console.log(`Failed to load image for ${artist.name}`); console.log(`Failed to load image for ${artist.name}`);
imageContainer.innerHTML = `<div class="similar-artist-bubble-image-fallback">🎵</div>`; imageContainer.innerHTML = `<div class="similar-artist-bubble-image-fallback">🎵</div>`;
bubble.setAttribute('data-needs-image', 'true');
}; };
imageContainer.appendChild(img); imageContainer.appendChild(img);
} else { } else {
// No image - show fallback // No image - show fallback (will be lazy loaded)
imageContainer.innerHTML = `<div class="similar-artist-bubble-image-fallback">🎵</div>`; imageContainer.innerHTML = `<div class="similar-artist-bubble-image-fallback">🎵</div>`;
} }
@ -20817,8 +21052,24 @@ function updateArtistDetailHeader(artist) {
const nameElement = document.getElementById('search-artist-detail-name'); const nameElement = document.getElementById('search-artist-detail-name');
const genresElement = document.getElementById('search-artist-detail-genres'); const genresElement = document.getElementById('search-artist-detail-genres');
if (imageElement && artist.image_url) { if (imageElement) {
if (artist.image_url) {
imageElement.style.backgroundImage = `url('${artist.image_url}')`; imageElement.style.backgroundImage = `url('${artist.image_url}')`;
} else {
// Lazy load image if missing (common for iTunes artists)
console.log(`🖼️ Lazy loading detail image for ${artist.name} (${artist.id})`);
fetch(`/api/artist/${artist.id}/image`)
.then(response => response.json())
.then(data => {
if (data.success && data.image_url) {
console.log(`✅ Loaded detail image for ${artist.name}`);
imageElement.style.backgroundImage = `url('${data.image_url}')`;
// Update the artist object in memory too
artist.image_url = data.image_url;
}
})
.catch(err => console.error('❌ Failed to load detail image:', err));
}
} }
if (nameElement) { if (nameElement) {
@ -21939,8 +22190,35 @@ async function openSearchDownloadModal(artistName) {
document.body.appendChild(modal); document.body.appendChild(modal);
modal.style.display = 'flex'; modal.style.display = 'flex';
// Start monitoring for status changes
// Start monitoring for status changes // Start monitoring for status changes
monitorSearchDownloadModal(artistName); monitorSearchDownloadModal(artistName);
// Lazy load artist image if missing (common for iTunes)
if (!artistBubbleData.artist.image_url) {
console.log(`🖼️ Lazy loading modal image for ${artistBubbleData.artist.name} (${artistBubbleData.artist.id})`);
fetch(`/api/artist/${artistBubbleData.artist.id}/image`)
.then(response => response.json())
.then(data => {
if (data.success && data.image_url) {
// Update header background
const headerBg = modal.querySelector('.artist-download-modal-hero-bg');
if (headerBg) {
headerBg.style.backgroundImage = `url('${data.image_url}')`;
}
// Update avatar
const avatarContainer = modal.querySelector('.artist-download-modal-hero-avatar');
if (avatarContainer) {
avatarContainer.innerHTML = `<img src="${data.image_url}" alt="${artistBubbleData.artist.name}" class="artist-download-modal-hero-image" loading="lazy">`;
}
// Update artist object in memory
artistBubbleData.artist.image_url = data.image_url;
}
})
.catch(err => console.error('❌ Failed to load modal image:', err));
}
} }
/** /**
@ -22982,6 +23260,17 @@ function updateServiceStatus(service, statusData) {
statusText.className = 'service-card-status-text disconnected'; statusText.className = 'service-card-status-text disconnected';
} }
} }
// Update music source title (Spotify or Apple Music) based on active source
if (service === 'spotify' && statusData.source) {
const musicSourceTitleElement = document.getElementById('music-source-title');
if (musicSourceTitleElement) {
const sourceName = statusData.source === 'itunes' ? 'Apple Music' : 'Spotify';
musicSourceTitleElement.textContent = sourceName;
// Update global variable for use in discovery modals
currentMusicSourceName = sourceName;
}
}
} }
function updateSidebarServiceStatus(service, statusData) { function updateSidebarServiceStatus(service, statusData) {
@ -23006,6 +23295,15 @@ function updateSidebarServiceStatus(service, statusData) {
mediaServerNameElement.textContent = serverName; mediaServerNameElement.textContent = serverName;
} }
} }
// Update music source name (Spotify or Apple Music) based on active source
if (service === 'spotify' && statusData.source) {
const musicSourceNameElement = document.getElementById('music-source-name');
if (musicSourceNameElement) {
const sourceName = statusData.source === 'itunes' ? 'Apple Music' : 'Spotify';
musicSourceNameElement.textContent = sourceName;
}
}
} }
} }
@ -23414,7 +23712,7 @@ async function showWatchlistModal() {
${artistsData.artists.map(artist => ` ${artistsData.artists.map(artist => `
<div class="watchlist-artist-item" <div class="watchlist-artist-item"
data-artist-name="${artist.artist_name.toLowerCase().replace(/"/g, '&quot;')}" data-artist-name="${artist.artist_name.toLowerCase().replace(/"/g, '&quot;')}"
data-artist-id="${artist.spotify_artist_id}" data-artist-id="${artist.spotify_artist_id || artist.itunes_artist_id}"
style="cursor: pointer;"> style="cursor: pointer;">
${artist.image_url ? ` ${artist.image_url ? `
<img src="${artist.image_url}" <img src="${artist.image_url}"
@ -23434,7 +23732,7 @@ async function showWatchlistModal() {
` : ''} ` : ''}
</div> </div>
<button class="playlist-modal-btn playlist-modal-btn-secondary watchlist-remove-btn" <button class="playlist-modal-btn playlist-modal-btn-secondary watchlist-remove-btn"
data-artist-id="${artist.spotify_artist_id}" data-artist-id="${artist.spotify_artist_id || artist.itunes_artist_id}"
data-artist-name="${escapeHtml(artist.artist_name)}" data-artist-name="${escapeHtml(artist.artist_name)}"
onclick="event.stopPropagation();"> onclick="event.stopPropagation();">
Remove Remove
@ -25411,7 +25709,7 @@ function createReleaseCard(release) {
name: release.title, name: release.title,
image_url: release.image_url, image_url: release.image_url,
release_date: release.year ? `${release.year}-01-01` : '', release_date: release.year ? `${release.year}-01-01` : '',
album_type: release.type || 'album', album_type: release.album_type || release.type || 'album',
total_tracks: (release.track_completion && typeof release.track_completion === 'object') total_tracks: (release.track_completion && typeof release.track_completion === 'object')
? release.track_completion.total_tracks : 1 ? release.track_completion.total_tracks : 1
}; };
@ -25440,8 +25738,8 @@ function createReleaseCard(release) {
throw new Error('No tracks found for this release'); throw new Error('No tracks found for this release');
} }
// Determine album type based on release data // Use the actual album type from release data
const albumType = release.type === 'single' ? 'singles' : 'albums'; const albumType = release.album_type || release.type || 'album';
// Open the Add to Wishlist modal // Open the Add to Wishlist modal
// Note: openAddToWishlistModal has its own loading overlay // Note: openAddToWishlistModal has its own loading overlay
@ -29833,20 +30131,28 @@ function displayDiscoverHeroArtist(artist) {
} }
// Store artist ID for both buttons and update watchlist state // Store artist ID for both buttons and update watchlist state
// Use artist_id which is set by the backend to the appropriate ID for the active source
const addBtn = document.getElementById('discover-hero-add'); const addBtn = document.getElementById('discover-hero-add');
const discographyBtn = document.getElementById('discover-hero-discography'); const discographyBtn = document.getElementById('discover-hero-discography');
const artistId = artist.artist_id || artist.spotify_artist_id || artist.itunes_artist_id;
if (addBtn && artist.spotify_artist_id) { if (addBtn && artistId) {
addBtn.setAttribute('data-artist-id', artist.spotify_artist_id); addBtn.setAttribute('data-artist-id', artistId);
addBtn.setAttribute('data-artist-name', artist.artist_name); addBtn.setAttribute('data-artist-name', artist.artist_name);
// Also store both IDs for cross-source operations
if (artist.spotify_artist_id) addBtn.setAttribute('data-spotify-id', artist.spotify_artist_id);
if (artist.itunes_artist_id) addBtn.setAttribute('data-itunes-id', artist.itunes_artist_id);
// Check if this artist is already in watchlist and update button appearance // Check if this artist is already in watchlist and update button appearance
checkAndUpdateDiscoverHeroWatchlistButton(artist.spotify_artist_id); checkAndUpdateDiscoverHeroWatchlistButton(artistId);
} }
if (discographyBtn && artist.spotify_artist_id) { if (discographyBtn && artistId) {
discographyBtn.setAttribute('data-artist-id', artist.spotify_artist_id); discographyBtn.setAttribute('data-artist-id', artistId);
discographyBtn.setAttribute('data-artist-name', artist.artist_name); discographyBtn.setAttribute('data-artist-name', artist.artist_name);
// Also store both IDs for cross-source operations
if (artist.spotify_artist_id) discographyBtn.setAttribute('data-spotify-id', artist.spotify_artist_id);
if (artist.itunes_artist_id) discographyBtn.setAttribute('data-itunes-id', artist.itunes_artist_id);
} }
// Update slideshow indicators // Update slideshow indicators
@ -32889,8 +33195,16 @@ async function openDownloadModalForRecentAlbum(albumIndex) {
showLoadingOverlay(`Loading tracks for ${album.album_name}...`); showLoadingOverlay(`Loading tracks for ${album.album_name}...`);
try { try {
// Fetch album tracks from Spotify API via backend // Determine source and album ID - use source-agnostic endpoint
const response = await fetch(`/api/spotify/album/${album.album_spotify_id}`); const source = album.source || (album.album_spotify_id ? 'spotify' : 'itunes');
const albumId = source === 'spotify' ? album.album_spotify_id : album.album_itunes_id;
if (!albumId) {
throw new Error(`No ${source} album ID available`);
}
// Fetch album tracks from appropriate source via backend
const response = await fetch(`/api/discover/album/${source}/${albumId}`);
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch album tracks'); throw new Error('Failed to fetch album tracks');
} }
@ -32925,13 +33239,14 @@ async function openDownloadModalForRecentAlbum(albumIndex) {
}; };
}); });
// Create virtual playlist ID // Create virtual playlist ID using the appropriate album ID
const virtualPlaylistId = `discover_album_${album.album_spotify_id}`; const virtualPlaylistId = `discover_album_${albumId}`;
// CRITICAL FIX: Pass proper artist/album context for modal display // CRITICAL FIX: Pass proper artist/album context for modal display
const artistContext = { const artistContext = {
id: album.artist_spotify_id, id: source === 'spotify' ? album.artist_spotify_id : album.artist_itunes_id,
name: album.artist_name name: album.artist_name,
source: source
}; };
const albumContext = { const albumContext = {