Add Deezer as configurable free metadata fallback source alongside iTunes

Users can now choose between iTunes/Apple Music and Deezer as their free
metadata source in Settings. Spotify always takes priority when authenticated;
the fallback handles all lookups when it's not.

Core changes:
- DeezerClient: full metadata interface (search, albums, artists, tracks)
  matching iTunesClient's API surface with identical dataclass return types
- SpotifyClient: configurable _fallback property switches between iTunes/Deezer
  based on live config reads (no restart needed)
- MetadataService, web_server, watchlist_scanner, api/search, repair_worker,
  seasonal_discovery, personalized_playlists: all direct iTunesClient imports
  replaced with fallback-aware helpers

Database:
- deezer_artist_id on watchlist_artists and similar_artists tables
- deezer_track_id/album_id/artist_id on discovery_pool and discovery_cache
- Full CRUD for Deezer IDs: add, read, update, backfill, metadata enrichment
- Watchlist duplicate detection by artist name prevents re-adding across sources
- SimilarArtist dataclass and all query/insert methods handle Deezer columns

Bug fixes found during review:
- Similar artist backfill was writing Deezer IDs into iTunes columns
- Discover hero was storing resolved Deezer IDs in wrong column
- Status cache not invalidating on settings save (source name lag)
- Watchlist add allowing duplicates when switching metadata sources
This commit is contained in:
Broque Thomas 2026-03-16 13:12:12 -07:00
parent a02d14a23a
commit 46ac46134b
14 changed files with 1620 additions and 605 deletions

View file

@ -48,13 +48,14 @@ def register_routes(bp):
tracks = [_serialize_track(t) for t in results]
return api_success({"tracks": tracks, "source": "spotify"})
if source in ("itunes", "auto"):
from core.itunes_client import iTunesClient
itunes = iTunesClient()
results = itunes.search_tracks(query, limit=limit)
if source in ("itunes", "deezer", "auto"):
from core.metadata_service import _create_fallback_client, _get_configured_fallback_source
fallback = _create_fallback_client()
fallback_source = _get_configured_fallback_source()
results = fallback.search_tracks(query, limit=limit)
if results:
tracks = [_serialize_track(t) for t in results]
return api_success({"tracks": tracks, "source": "itunes"})
return api_success({"tracks": tracks, "source": fallback_source})
return api_success({"tracks": [], "source": source})
except Exception as e:
@ -85,12 +86,13 @@ def register_routes(bp):
"source": "spotify",
})
from core.itunes_client import iTunesClient
itunes = iTunesClient()
results = itunes.search_albums(query, limit=limit)
from core.metadata_service import _create_fallback_client, _get_configured_fallback_source
fallback = _create_fallback_client()
fallback_source = _get_configured_fallback_source()
results = fallback.search_albums(query, limit=limit)
return api_success({
"albums": [_serialize_album(a) for a in results] if results else [],
"source": "itunes",
"source": fallback_source,
})
except Exception as e:
return api_error("SEARCH_ERROR", str(e), 500)
@ -120,12 +122,13 @@ def register_routes(bp):
"source": "spotify",
})
from core.itunes_client import iTunesClient
itunes = iTunesClient()
results = itunes.search_artists(query, limit=limit)
from core.metadata_service import _create_fallback_client, _get_configured_fallback_source
fallback = _create_fallback_client()
fallback_source = _get_configured_fallback_source()
results = fallback.search_artists(query, limit=limit)
return api_success({
"artists": [_serialize_artist(a) for a in results] if results else [],
"source": "itunes",
"source": fallback_source,
})
except Exception as e:
return api_error("SEARCH_ERROR", str(e), 500)

View file

@ -4,7 +4,9 @@ import time
import threading
from typing import Dict, List, Optional, Any
from functools import wraps
from dataclasses import dataclass
from utils.logging_config import get_logger
from core.metadata_cache import get_metadata_cache
logger = get_logger("deezer_client")
@ -40,8 +42,160 @@ def rate_limited(func):
return wrapper
# ==================== Dataclasses (match iTunesClient / SpotifyClient format) ====================
@dataclass
class Track:
id: str
name: str
artists: List[str]
album: str
duration_ms: int
popularity: int
preview_url: Optional[str] = None
external_urls: Optional[Dict[str, str]] = None
image_url: Optional[str] = None
release_date: Optional[str] = None
track_number: Optional[int] = None
disc_number: Optional[int] = None
@classmethod
def from_deezer_track(cls, track_data: Dict[str, Any]) -> 'Track':
# Extract album image
album_data = track_data.get('album', {})
album_image_url = None
if isinstance(album_data, dict):
album_image_url = album_data.get('cover_xl') or album_data.get('cover_big') or album_data.get('cover_medium')
# Get artist name
artist_data = track_data.get('artist', {})
artist_name = artist_data.get('name', 'Unknown Artist') if isinstance(artist_data, dict) else 'Unknown Artist'
# Get album name
album_name = ''
if isinstance(album_data, dict):
album_name = album_data.get('title', '')
elif isinstance(album_data, str):
album_name = album_data
# Build external URLs
external_urls = {}
if track_data.get('link'):
external_urls['deezer'] = track_data['link']
return cls(
id=str(track_data.get('id', '')),
name=track_data.get('title', ''),
artists=[artist_name],
album=album_name,
duration_ms=track_data.get('duration', 0) * 1000, # Deezer returns seconds
popularity=track_data.get('rank', 0),
preview_url=track_data.get('preview'),
external_urls=external_urls if external_urls else None,
image_url=album_image_url,
release_date=track_data.get('release_date') or (album_data.get('release_date') if isinstance(album_data, dict) else None),
track_number=track_data.get('track_position'),
disc_number=track_data.get('disk_number', 1),
)
@dataclass
class Artist:
id: str
name: str
popularity: int
genres: List[str]
followers: int
image_url: Optional[str] = None
external_urls: Optional[Dict[str, str]] = None
@classmethod
def from_deezer_artist(cls, artist_data: Dict[str, Any]) -> 'Artist':
image_url = artist_data.get('picture_xl') or artist_data.get('picture_big') or artist_data.get('picture_medium')
external_urls = {}
if artist_data.get('link'):
external_urls['deezer'] = artist_data['link']
return cls(
id=str(artist_data.get('id', '')),
name=artist_data.get('name', ''),
popularity=0,
genres=[],
followers=artist_data.get('nb_fan', 0),
image_url=image_url,
external_urls=external_urls if external_urls else None
)
@dataclass
class Album:
id: str
name: str
artists: List[str]
release_date: str
total_tracks: int
album_type: str
image_url: Optional[str] = None
external_urls: Optional[Dict[str, str]] = None
@classmethod
def from_deezer_album(cls, album_data: Dict[str, Any]) -> 'Album':
image_url = album_data.get('cover_xl') or album_data.get('cover_big') or album_data.get('cover_medium')
external_urls = {}
if album_data.get('link'):
external_urls['deezer'] = album_data['link']
artist_data = album_data.get('artist', {})
artist_name = artist_data.get('name', 'Unknown Artist') if isinstance(artist_data, dict) else 'Unknown Artist'
# Map Deezer record_type
record_type = album_data.get('record_type', 'album')
if record_type == 'single':
album_type = 'single'
elif record_type == 'ep':
album_type = 'ep'
elif record_type == 'compile':
album_type = 'compilation'
else:
album_type = 'album'
return cls(
id=str(album_data.get('id', '')),
name=album_data.get('title', ''),
artists=[artist_name],
release_date=album_data.get('release_date', ''),
total_tracks=album_data.get('nb_tracks', 0),
album_type=album_type,
image_url=image_url,
external_urls=external_urls if external_urls else None
)
@dataclass
class Playlist:
id: str
name: str
description: Optional[str]
owner: str
public: bool
collaborative: bool
tracks: List[Track]
total_tracks: int
class DeezerClient:
"""Client for interacting with the Deezer API"""
"""
Deezer API client for music metadata and playlist access.
Provides metadata parity with iTunesClient for use as a fallback source.
Also provides enrichment methods (search_artist, search_album, search_track)
and playlist methods used by deezer_worker.py.
Free, no authentication required.
Rate limit: ~50 calls/5s.
"""
BASE_URL = "https://api.deezer.com"
@ -53,10 +207,441 @@ class DeezerClient:
})
logger.info("Deezer client initialized")
def is_authenticated(self) -> bool:
"""Deezer public API requires no authentication — always available"""
return True
def reload_config(self):
"""Reload configuration (no-op for Deezer since no auth required)"""
pass
def _api_get(self, endpoint: str, params: dict = None, timeout: int = 15) -> Optional[Dict[str, Any]]:
"""Generic GET request to Deezer API with error handling"""
try:
url = f"{self.BASE_URL}/{endpoint.lstrip('/')}"
response = self.session.get(url, params=params, timeout=timeout)
if response.status_code != 200:
logger.error(f"Deezer API returned status {response.status_code} for {endpoint}")
return None
data = response.json()
if 'error' in data:
error = data['error']
error_type = error.get('type', 'Unknown')
error_msg = error.get('message', 'Unknown error')
if error_type == 'DataException':
logger.debug(f"Deezer data not found: {endpoint}")
else:
logger.error(f"Deezer API error ({error_type}): {error_msg}")
return None
return data
except Exception as e:
logger.error(f"Error in Deezer API request ({endpoint}): {e}")
return None
# ==================== Metadata Source Methods (iTunesClient parity) ====================
# These methods follow the same interface as iTunesClient so DeezerClient
# can serve as a drop-in fallback metadata source in SpotifyClient.
@rate_limited
def search_tracks(self, query: str, limit: int = 20) -> List[Track]:
"""Search for tracks — returns Track dataclass list (metadata source interface)"""
cache = get_metadata_cache()
cached_results = cache.get_search_results('deezer', 'track', query, limit)
if cached_results is not None:
tracks = []
for raw in cached_results:
try:
tracks.append(Track.from_deezer_track(raw))
except Exception:
pass
if tracks:
return tracks
data = self._api_get('search/track', {'q': query, 'limit': min(limit, 100)})
if not data or 'data' not in data:
return []
tracks = []
raw_items = []
for track_data in data['data']:
track = Track.from_deezer_track(track_data)
tracks.append(track)
raw_items.append(track_data)
entries = [(str(td.get('id', '')), td) for td in raw_items if td.get('id')]
if entries:
cache.store_entities_bulk('deezer', 'track', entries)
cache.store_search_results('deezer', 'track', query, limit,
[str(td.get('id', '')) for td in raw_items if td.get('id')])
return tracks
@rate_limited
def search_artists(self, query: str, limit: int = 20) -> List[Artist]:
"""Search for artists — returns Artist dataclass list (metadata source interface)"""
cache = get_metadata_cache()
cached_results = cache.get_search_results('deezer', 'artist', query, limit)
if cached_results is not None:
artists = []
for raw in cached_results:
try:
artists.append(Artist.from_deezer_artist(raw))
except Exception:
pass
if artists:
return artists
data = self._api_get('search/artist', {'q': query, 'limit': min(limit, 100)})
if not data or 'data' not in data:
return []
artists = []
raw_items = []
for artist_data in data['data']:
artist = Artist.from_deezer_artist(artist_data)
artists.append(artist)
raw_items.append(artist_data)
entries = [(str(ad.get('id', '')), ad) for ad in raw_items if ad.get('id')]
if entries:
cache.store_entities_bulk('deezer', 'artist', entries)
cache.store_search_results('deezer', 'artist', query, limit,
[str(ad.get('id', '')) for ad in raw_items if ad.get('id')])
return artists
@rate_limited
def search_albums(self, query: str, limit: int = 20) -> List[Album]:
"""Search for albums — returns Album dataclass list (metadata source interface)"""
cache = get_metadata_cache()
cached_results = cache.get_search_results('deezer', 'album', query, limit)
if cached_results is not None:
albums = []
for raw in cached_results:
try:
albums.append(Album.from_deezer_album(raw))
except Exception:
pass
if albums:
return albums
data = self._api_get('search/album', {'q': query, 'limit': min(limit, 100)})
if not data or 'data' not in data:
return []
albums = []
raw_items = []
for album_data in data['data']:
album = Album.from_deezer_album(album_data)
albums.append(album)
raw_items.append(album_data)
entries = [(str(ad.get('id', '')), ad) for ad in raw_items if ad.get('id')]
if entries:
cache.store_entities_bulk('deezer', 'album', entries, skip_if_exists=True)
cache.store_search_results('deezer', 'album', query, limit,
[str(ad.get('id', '')) for ad in raw_items if ad.get('id')])
return albums[:limit]
def get_track_details(self, track_id: str) -> Optional[Dict[str, Any]]:
"""Get detailed track info — returns Spotify-compatible dict (metadata source interface)"""
cache = get_metadata_cache()
cached = cache.get_entity('deezer', 'track', str(track_id))
if cached and cached.get('title'):
return self._build_enhanced_track(cached)
data = self._api_get(f'track/{track_id}')
if not data:
return None
cache.store_entity('deezer', 'track', str(track_id), data)
return self._build_enhanced_track(data)
def _build_enhanced_track(self, track_data: Dict[str, Any]) -> Dict[str, Any]:
"""Build Spotify-compatible enhanced track dict from raw Deezer data"""
artist_data = track_data.get('artist', {})
album_data = track_data.get('album', {})
artist_name = artist_data.get('name', 'Unknown Artist') if isinstance(artist_data, dict) else 'Unknown Artist'
album_name = album_data.get('title', '') if isinstance(album_data, dict) else str(album_data) if album_data else ''
album_id = str(album_data.get('id', '')) if isinstance(album_data, dict) else ''
return {
'id': str(track_data.get('id', '')),
'name': track_data.get('title', ''),
'track_number': track_data.get('track_position', 0),
'disc_number': track_data.get('disk_number', 1),
'duration_ms': track_data.get('duration', 0) * 1000,
'explicit': track_data.get('explicit_lyrics', False),
'artists': [artist_name],
'primary_artist': artist_name,
'album': {
'id': album_id,
'name': album_name,
'total_tracks': album_data.get('nb_tracks', 0) if isinstance(album_data, dict) else 0,
'release_date': track_data.get('release_date', '') or (album_data.get('release_date', '') if isinstance(album_data, dict) else ''),
'album_type': 'album',
'artists': [artist_name]
},
'is_album_track': (album_data.get('nb_tracks', 0) if isinstance(album_data, dict) else 0) > 1,
'raw_data': track_data
}
def get_track_features(self, track_id: str) -> Optional[Dict[str, Any]]:
"""Deezer does not provide audio features like Spotify"""
return None
def get_album_metadata(self, album_id: str, include_tracks: bool = True) -> Optional[Dict[str, Any]]:
"""Get album info — returns Spotify-compatible dict (metadata source interface).
Matches iTunesClient.get_album() interface. The enrichment method below
is get_album_raw() (used by deezer_worker.py)."""
cache = get_metadata_cache()
cached = cache.get_entity('deezer', 'album', str(album_id))
if cached and cached.get('title'):
return self._build_album_result(cached, album_id, include_tracks)
data = self._api_get(f'album/{album_id}')
if not data:
return None
cache.store_entity('deezer', 'album', str(album_id), data)
return self._build_album_result(data, album_id, include_tracks)
def _build_album_result(self, album_data: Dict[str, Any], album_id: str, include_tracks: bool = True) -> Dict[str, Any]:
"""Build Spotify-compatible album result from Deezer data"""
images = []
for size_key, height in [('cover_xl', 1000), ('cover_big', 500), ('cover_medium', 250), ('cover_small', 56)]:
if album_data.get(size_key):
images.append({'url': album_data[size_key], 'height': height, 'width': height})
artist_data = album_data.get('artist', {})
artist_name = artist_data.get('name', 'Unknown Artist') if isinstance(artist_data, dict) else 'Unknown Artist'
artist_id = str(artist_data.get('id', '')) if isinstance(artist_data, dict) else ''
record_type = album_data.get('record_type', 'album')
if record_type == 'single':
album_type = 'single'
elif record_type == 'ep':
album_type = 'ep'
elif record_type == 'compile':
album_type = 'compilation'
else:
album_type = 'album'
album_result = {
'id': str(album_data.get('id', album_id)),
'name': album_data.get('title', ''),
'images': images,
'artists': [{'name': artist_name, 'id': artist_id}],
'release_date': album_data.get('release_date', ''),
'total_tracks': album_data.get('nb_tracks', 0),
'album_type': album_type,
'external_urls': {'deezer': album_data.get('link', '')},
'uri': f"deezer:album:{album_data.get('id', '')}",
'_source': 'deezer',
'_raw_data': album_data
}
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
def get_album_tracks(self, album_id: str) -> Optional[Dict[str, Any]]:
"""Get album tracks — returns Spotify-compatible format (metadata source interface)"""
cache = get_metadata_cache()
cached = cache.get_entity('deezer', 'album', f"{album_id}_tracks")
if cached:
return cached
data = self._api_get(f'album/{album_id}/tracks', {'limit': 500})
if not data or 'data' not in data:
album_data = self._api_get(f'album/{album_id}')
if album_data and 'tracks' in album_data and 'data' in album_data['tracks']:
data = album_data['tracks']
else:
return None
# Get album-level info for images and name
album_info = self._api_get(f'album/{album_id}')
album_images = []
album_name = ''
if album_info:
album_name = album_info.get('title', '')
for size_key, height in [('cover_xl', 1000), ('cover_big', 500), ('cover_medium', 250)]:
if album_info.get(size_key):
album_images.append({'url': album_info[size_key], 'height': height, 'width': height})
tracks = []
for item in data['data']:
artist_data = item.get('artist', {})
artist_name = artist_data.get('name', 'Unknown Artist') if isinstance(artist_data, dict) else 'Unknown Artist'
normalized_track = {
'id': str(item.get('id', '')),
'name': item.get('title', ''),
'artists': [{'name': artist_name}],
'album': {
'id': str(album_id),
'name': album_name,
'images': album_images,
'release_date': album_info.get('release_date', '') if album_info else ''
},
'duration_ms': item.get('duration', 0) * 1000,
'track_number': item.get('track_position', 0),
'disc_number': item.get('disk_number', 1),
'explicit': item.get('explicit_lyrics', False),
'preview_url': item.get('preview'),
'uri': f"deezer:track:{item.get('id', '')}",
'external_urls': {'deezer': item.get('link', '')},
'_source': 'deezer'
}
tracks.append(normalized_track)
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}")
result = {
'items': tracks,
'total': len(tracks),
'limit': len(tracks),
'next': None
}
cache.store_entity('deezer', 'album', f"{album_id}_tracks", result)
# Cache individual tracks
for item in data['data']:
if item.get('id'):
cache.store_entity('deezer', 'track', str(item['id']), item)
return result
def get_artist_info(self, artist_id: str) -> Optional[Dict[str, Any]]:
"""Get full artist details — returns Spotify-compatible dict (metadata source interface).
Matches iTunesClient.get_artist() interface."""
cache = get_metadata_cache()
cached = cache.get_entity('deezer', 'artist', str(artist_id))
if cached and cached.get('name'):
return self._build_artist_result(cached)
data = self._api_get(f'artist/{artist_id}')
if not data:
return None
cache.store_entity('deezer', 'artist', str(artist_id), data)
return self._build_artist_result(data)
def _build_artist_result(self, artist_data: Dict[str, Any]) -> Dict[str, Any]:
"""Build Spotify-compatible artist result from Deezer data"""
images = []
for size_key, height in [('picture_xl', 1000), ('picture_big', 500), ('picture_medium', 250), ('picture_small', 56)]:
if artist_data.get(size_key):
images.append({'url': artist_data[size_key], 'height': height, 'width': height})
return {
'id': str(artist_data.get('id', '')),
'name': artist_data.get('name', ''),
'images': images,
'genres': [],
'popularity': 0,
'followers': {'total': artist_data.get('nb_fan', 0)},
'external_urls': {'deezer': artist_data.get('link', '')},
'uri': f"deezer:artist:{artist_data.get('id', '')}",
'_source': 'deezer',
'_raw_data': artist_data
}
def get_artist_albums_list(self, artist_id: str, album_type: str = 'album,single', limit: int = 50) -> List[Album]:
"""Get albums by artist ID — returns Album dataclass list (metadata source interface).
Matches iTunesClient.get_artist_albums() interface."""
data = self._api_get(f'artist/{artist_id}/albums', {'limit': min(limit, 100)})
if not data or 'data' not in data:
return []
albums = []
requested_types = [t.strip() for t in album_type.split(',')]
for album_data in data['data']:
album = Album.from_deezer_album(album_data)
if album_type != 'album,single':
if album.album_type not in requested_types:
if not (album.album_type == 'ep' and 'single' in requested_types):
continue
albums.append(album)
cache = get_metadata_cache()
entries = [(str(ad.get('id', '')), ad) for ad in data['data'] if ad.get('id')]
if entries:
cache.store_entities_bulk('deezer', 'album', entries, skip_if_exists=True)
logger.info(f"Retrieved {len(albums)} albums for artist {artist_id}")
return albums[:limit]
# ==================== Interface Aliases (match iTunesClient method names) ====================
# These allow SpotifyClient to call self._fallback.get_album() etc. without
# conditional dispatch — same method names as iTunesClient.
get_album = get_album_metadata
get_artist = get_artist_info
get_artist_albums = get_artist_albums_list
def _get_artist_image_from_albums(self, artist_id: str) -> Optional[str]:
"""Compatibility with iTunesClient — Deezer artists have direct image URLs."""
artist_data = self._api_get(f'artist/{artist_id}')
if artist_data:
return artist_data.get('picture_xl') or artist_data.get('picture_big') or artist_data.get('picture_medium')
return None
# ==================== Stub Methods (match iTunesClient interface) ====================
def get_user_playlists(self) -> List[Playlist]:
"""Not supported — Deezer playlists require auth"""
return []
def get_user_playlists_metadata_only(self) -> List[Playlist]:
"""Not supported"""
return []
def get_saved_tracks_count(self) -> int:
"""Not supported"""
return 0
def get_saved_tracks(self) -> List[Track]:
"""Not supported"""
return []
def get_playlist_by_id(self, playlist_id: str) -> Optional[Playlist]:
"""Not supported"""
return None
def get_user_info(self) -> Optional[Dict[str, Any]]:
"""Not supported — requires auth"""
return None
# ==================== Existing Enrichment Methods ====================
# These methods are used by deezer_worker.py and web_server.py enrichment endpoints.
# They have different signatures from the metadata-source methods above.
@rate_limited
def search_artist(self, artist_name: str) -> Optional[Dict[str, Any]]:
"""
Search for an artist by name.
Search for an artist by name (enrichment interface).
Args:
artist_name: Name of the artist to search for
@ -92,7 +677,7 @@ class DeezerClient:
@rate_limited
def search_album(self, artist_name: str, album_title: str) -> Optional[Dict[str, Any]]:
"""
Search for an album by artist name and album title.
Search for an album by artist name and album title (enrichment interface).
Args:
artist_name: Name of the artist
@ -130,7 +715,7 @@ class DeezerClient:
@rate_limited
def search_track(self, artist_name: str, track_title: str) -> Optional[Dict[str, Any]]:
"""
Search for a track by artist name and track title.
Search for a track by artist name and track title (enrichment interface).
Args:
artist_name: Name of the artist
@ -166,9 +751,10 @@ class DeezerClient:
return None
@rate_limited
def get_album(self, album_id: int) -> Optional[Dict[str, Any]]:
def get_album_raw(self, album_id: int) -> Optional[Dict[str, Any]]:
"""
Get full album details by ID.
Get full album details by ID raw Deezer format (enrichment interface).
Used by deezer_worker.py for label/genre/explicit enrichment.
Args:
album_id: Deezer album ID
@ -196,9 +782,10 @@ class DeezerClient:
return None
@rate_limited
def get_track(self, track_id: int) -> Optional[Dict[str, Any]]:
def get_track_raw(self, track_id: int) -> Optional[Dict[str, Any]]:
"""
Get full track details by ID (includes BPM).
Get full track details by ID raw Deezer format (enrichment interface, includes BPM).
Used by deezer_worker.py for BPM enrichment.
Args:
track_id: Deezer track ID

View file

@ -363,7 +363,7 @@ class DeezerWorker:
full_album = None
if deezer_album_id:
try:
full_album = self.client.get_album(deezer_album_id)
full_album = self.client.get_album_raw(deezer_album_id)
except Exception as e:
logger.warning(f"Failed to fetch full album details for '{album_name}' (Deezer ID: {deezer_album_id}): {e}")
@ -403,7 +403,7 @@ class DeezerWorker:
full_track = None
if deezer_track_id:
try:
full_track = self.client.get_track(deezer_track_id)
full_track = self.client.get_track_raw(deezer_track_id)
except Exception as e:
logger.warning(f"Failed to fetch full track details for '{track_name}' (Deezer ID: {deezer_track_id}): {e}")

View file

@ -465,9 +465,9 @@ class MetadataCache:
cursor = conn.cursor()
stats = {
'artists': {'spotify': 0, 'itunes': 0},
'albums': {'spotify': 0, 'itunes': 0},
'tracks': {'spotify': 0, 'itunes': 0},
'artists': {'spotify': 0, 'itunes': 0, 'deezer': 0},
'albums': {'spotify': 0, 'itunes': 0, 'deezer': 0},
'tracks': {'spotify': 0, 'itunes': 0, 'deezer': 0},
'searches': 0,
'total_entries': 0,
'total_hits': 0,
@ -509,9 +509,9 @@ class MetadataCache:
except Exception as e:
logger.error(f"Cache stats error: {e}")
return {
'artists': {'spotify': 0, 'itunes': 0},
'albums': {'spotify': 0, 'itunes': 0},
'tracks': {'spotify': 0, 'itunes': 0},
'artists': {'spotify': 0, 'itunes': 0, 'deezer': 0},
'albums': {'spotify': 0, 'itunes': 0, 'deezer': 0},
'tracks': {'spotify': 0, 'itunes': 0, 'deezer': 0},
'searches': 0, 'total_entries': 0, 'total_hits': 0,
'oldest': None, 'newest': None,
}
@ -610,6 +610,8 @@ class MetadataCache:
return self._extract_spotify_fields(entity_type, raw_data)
elif source == 'itunes':
return self._extract_itunes_fields(entity_type, raw_data)
elif source == 'deezer':
return self._extract_deezer_fields(entity_type, raw_data)
return {'name': str(raw_data.get('name', raw_data.get('trackName', '')))}
def _extract_spotify_fields(self, entity_type: str, data: dict) -> dict:
@ -664,6 +666,60 @@ class MetadataCache:
return fields
def _extract_deezer_fields(self, entity_type: str, data: dict) -> dict:
"""Extract fields from Deezer API response."""
fields = {}
if entity_type == 'artist':
fields['name'] = data.get('name', '')
fields['genres'] = '[]'
fields['popularity'] = 0
fields['followers'] = data.get('nb_fan', 0)
fields['image_url'] = data.get('picture_xl') or data.get('picture_big') or data.get('picture_medium')
urls = {}
if data.get('link'):
urls['deezer'] = data['link']
fields['external_urls'] = json.dumps(urls)
elif entity_type == 'album':
fields['name'] = data.get('title', '')
artist = data.get('artist', {})
fields['artist_name'] = artist.get('name', '') if isinstance(artist, dict) else ''
fields['artist_id'] = str(artist.get('id', '')) if isinstance(artist, dict) else ''
fields['release_date'] = data.get('release_date', '')
fields['total_tracks'] = data.get('nb_tracks', 0)
record_type = data.get('record_type', 'album')
fields['album_type'] = record_type if record_type in ('single', 'ep', 'album') else 'album'
fields['label'] = data.get('label', '')
fields['image_url'] = data.get('cover_xl') or data.get('cover_big') or data.get('cover_medium')
urls = {}
if data.get('link'):
urls['deezer'] = data['link']
fields['external_urls'] = json.dumps(urls)
elif entity_type == 'track':
fields['name'] = data.get('title', '')
artist = data.get('artist', {})
fields['artist_name'] = artist.get('name', '') if isinstance(artist, dict) else ''
fields['artist_id'] = str(artist.get('id', '')) if isinstance(artist, dict) else ''
album = data.get('album', {})
fields['album_name'] = album.get('title', '') if isinstance(album, dict) else ''
fields['album_id'] = str(album.get('id', '')) if isinstance(album, dict) else ''
fields['image_url'] = (album.get('cover_xl') or album.get('cover_big') or album.get('cover_medium')) if isinstance(album, dict) else None
fields['duration_ms'] = data.get('duration', 0) * 1000
fields['track_number'] = data.get('track_position')
fields['disc_number'] = data.get('disk_number', 1)
fields['explicit'] = 1 if data.get('explicit_lyrics') else 0
fields['popularity'] = data.get('rank', 0)
fields['isrc'] = data.get('isrc')
fields['preview_url'] = data.get('preview')
urls = {}
if data.get('link'):
urls['deezer'] = data['link']
fields['external_urls'] = json.dumps(urls)
return fields
def _extract_itunes_fields(self, entity_type: str, data: dict) -> dict:
"""Extract fields from iTunes API response."""
fields = {}

View file

@ -1,7 +1,8 @@
"""
Metadata Service - Hot-swappable Spotify/iTunes provider
Metadata Service - Hot-swappable Spotify/iTunes/Deezer provider
Automatically uses Spotify when authenticated, falls back to iTunes when not.
Automatically uses Spotify when authenticated, falls back to the configured
fallback source (iTunes or Deezer) when not.
Provides unified interface for all metadata operations.
"""
@ -15,63 +16,83 @@ logger = get_logger("metadata_service")
MetadataProvider = Literal["spotify", "itunes", "auto"]
def _get_configured_fallback_source():
"""Get the configured metadata fallback source ('itunes' or 'deezer')."""
try:
from config.settings import config_manager
return config_manager.get('metadata.fallback_source', 'itunes') or 'itunes'
except Exception:
return 'itunes'
def _create_fallback_client():
"""Create the configured fallback metadata client."""
source = _get_configured_fallback_source()
if source == 'deezer':
from core.deezer_client import DeezerClient
return DeezerClient()
return iTunesClient()
class MetadataService:
"""
Unified metadata service that seamlessly switches between Spotify and iTunes.
Unified metadata service that seamlessly switches between Spotify and
the configured fallback source (iTunes or Deezer).
Usage:
service = MetadataService()
tracks = service.search_tracks("Radiohead OK Computer")
# Uses Spotify if authenticated, otherwise iTunes
# Uses Spotify if authenticated, otherwise configured fallback
"""
def __init__(self, preferred_provider: MetadataProvider = "auto"):
"""
Initialize metadata service.
Args:
preferred_provider: "spotify", "itunes", or "auto" (default)
- "auto": Use Spotify if authenticated, else iTunes
- "auto": Use Spotify if authenticated, else configured fallback
- "spotify": Always use Spotify (may fail if not authenticated)
- "itunes": Always use iTunes
- "itunes": Always use configured fallback source
"""
self.preferred_provider = preferred_provider
self.spotify = SpotifyClient()
self.itunes = iTunesClient()
self._fallback_source = _get_configured_fallback_source()
self.itunes = _create_fallback_client() # May be iTunesClient or DeezerClient
self._log_initialization()
def _log_initialization(self):
"""Log initialization status"""
spotify_status = "✅ Authenticated" if self.spotify.is_spotify_authenticated() else "❌ Not authenticated"
itunes_status = "✅ Available" if self.itunes.is_authenticated() else "❌ Not available"
logger.info(f"MetadataService initialized - Spotify: {spotify_status}, iTunes: {itunes_status}")
fallback_status = "✅ Available" if self.itunes.is_authenticated() else "❌ Not available"
logger.info(f"MetadataService initialized - Spotify: {spotify_status}, {self._fallback_source.capitalize()}: {fallback_status}")
logger.info(f"Preferred provider: {self.preferred_provider}")
def get_active_provider(self) -> str:
"""
Get the currently active metadata provider.
Returns:
"spotify" or "itunes"
"spotify" or the configured fallback source name
"""
if self.preferred_provider == "spotify":
return "spotify"
elif self.preferred_provider == "itunes":
return "itunes"
return self._fallback_source
else: # auto
# 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"
# (is_authenticated() always returns True due to fallback)
return "spotify" if self.spotify.is_spotify_authenticated() else self._fallback_source
def _get_client(self):
"""Get the appropriate client based on provider selection"""
provider = self.get_active_provider()
if provider == "spotify":
if not self.spotify.is_spotify_authenticated():
logger.warning("Spotify requested but not authenticated, falling back to iTunes")
logger.warning(f"Spotify requested but not authenticated, falling back to {self._fallback_source}")
return self.itunes
return self.spotify
else:
@ -200,6 +221,7 @@ class MetadataService:
"active_provider": self.get_active_provider(),
"spotify_authenticated": self.spotify.is_spotify_authenticated(),
"itunes_available": self.itunes.is_authenticated(),
"fallback_source": self._fallback_source,
"preferred_provider": self.preferred_provider,
"can_access_user_data": self.spotify.is_spotify_authenticated(),
}
@ -208,7 +230,13 @@ class MetadataService:
"""Reload configuration for both clients"""
logger.info("Reloading metadata service configuration")
self.spotify.reload_config()
self.itunes.reload_config()
# Re-create fallback client in case the setting changed
new_source = _get_configured_fallback_source()
if new_source != self._fallback_source:
self._fallback_source = new_source
self.itunes = _create_fallback_client()
elif hasattr(self.itunes, 'reload_config'):
self.itunes.reload_config()
self._log_initialization()

View file

@ -951,8 +951,8 @@ class PersonalizedPlaylistsService:
logger.warning(f"Error getting albums for {artist.get('name', artist['id'])}: {e}")
continue
else:
from core.itunes_client import iTunesClient
itunes = iTunesClient()
from core.metadata_service import _create_fallback_client
itunes = _create_fallback_client()
for artist in artists_for_albums:
try:
albums = itunes.get_artist_albums(artist['id'], limit=10)
@ -1007,8 +1007,8 @@ class PersonalizedPlaylistsService:
logger.warning(f"Error getting tracks from album: {e}")
continue
else:
from core.itunes_client import iTunesClient
itunes = iTunesClient()
from core.metadata_service import _create_fallback_client
itunes = _create_fallback_client()
for album in selected_albums:
try:
album_data = itunes.get_album(album.id, include_tracks=True)

View file

@ -146,10 +146,10 @@ class RepairWorker:
def itunes_client(self):
if self._itunes_client is None:
try:
from core.itunes_client import iTunesClient
self._itunes_client = iTunesClient()
from core.metadata_service import _create_fallback_client
self._itunes_client = _create_fallback_client()
except Exception as e:
logger.error("Failed to initialize iTunesClient: %s", e)
logger.error("Failed to initialize fallback metadata client: %s", e)
return self._itunes_client
@property

View file

@ -448,6 +448,10 @@ class SeasonalDiscoveryService:
# IMPROVED: Sample 20 random watchlist artists (up from 10) for more variety
sampled_artists = random.sample(watchlist_artists, min(20, len(watchlist_artists)))
from core.metadata_service import _create_fallback_client, _get_configured_fallback_source
fallback_client = _create_fallback_client()
fallback_source = _get_configured_fallback_source()
for artist in sampled_artists:
try:
albums = []
@ -457,14 +461,14 @@ class SeasonalDiscoveryService:
album_type='album,single,ep',
limit=50
) or []
elif hasattr(artist, 'itunes_artist_id') and artist.itunes_artist_id:
from core.itunes_client import iTunesClient
itunes_client = iTunesClient()
albums = itunes_client.get_artist_albums(
artist.itunes_artist_id,
album_type='album,single,ep',
limit=50
) or []
elif not use_spotify:
artist_id = getattr(artist, 'deezer_artist_id', None) if fallback_source == 'deezer' else getattr(artist, 'itunes_artist_id', None)
if artist_id:
albums = fallback_client.get_artist_albums(
artist_id,
album_type='album,single,ep',
limit=50
) or []
# Filter albums by seasonal keywords in title
for album in albums:
@ -547,13 +551,13 @@ class SeasonalDiscoveryService:
logger.debug(f"Error searching Spotify for '{keyword}': {e}")
continue
else:
# iTunes fallback
from core.itunes_client import iTunesClient
itunes_client = iTunesClient()
# Fallback metadata source (iTunes or Deezer)
from core.metadata_service import _create_fallback_client
fallback_client = _create_fallback_client()
for keyword in search_keywords:
try:
search_results = itunes_client.search_albums(keyword, limit=20)
search_results = fallback_client.search_albums(keyword, limit=20)
for album in search_results:
if album.id in seen_album_ids:
@ -765,6 +769,9 @@ class SeasonalDiscoveryService:
seasonal_albums = self.get_seasonal_albums(season_key, limit=50, source=source)
use_spotify = self.spotify_client and self.spotify_client.is_authenticated()
if not use_spotify:
from core.metadata_service import _create_fallback_client
fallback_client = _create_fallback_client()
for album in seasonal_albums:
try:
@ -774,10 +781,7 @@ class SeasonalDiscoveryService:
if use_spotify:
album_data = self.spotify_client.get_album(album_id)
else:
# iTunes fallback
from core.itunes_client import iTunesClient
itunes_client = iTunesClient()
album_data = itunes_client.get_album(album_id)
album_data = fallback_client.get_album(album_id)
if not album_data or 'tracks' not in album_data:
continue

View file

@ -407,6 +407,7 @@ class SpotifyClient:
self.sp: Optional[spotipy.Spotify] = None
self.user_id: Optional[str] = None
self._itunes_client = None # Lazy-loaded iTunes fallback
self._deezer_client = None # Lazy-loaded Deezer fallback
self._auth_cache_lock = threading.Lock()
self._auth_cached_result: Optional[bool] = None
self._auth_cache_time: float = 0
@ -414,27 +415,51 @@ class SpotifyClient:
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)"""
"""Check if an ID is a Spotify ID (alphanumeric) vs a fallback source ID (numeric only)"""
if not id_str:
return False
# Spotify IDs contain letters and numbers, iTunes IDs are purely numeric
# Spotify IDs contain letters and numbers; iTunes/Deezer 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)"""
"""Check if an ID is numeric (iTunes or Deezer format, not Spotify)"""
if not id_str:
return False
return id_str.isdigit()
@property
def _itunes(self):
"""Lazy-load iTunes client for fallback when Spotify not authenticated"""
"""Lazy-load iTunes client"""
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
@property
def _deezer(self):
"""Lazy-load Deezer client for metadata fallback"""
if self._deezer_client is None:
from core.deezer_client import DeezerClient
self._deezer_client = DeezerClient()
logger.info("Deezer fallback client initialized")
return self._deezer_client
@property
def _fallback_source(self) -> str:
"""Get configured metadata fallback source ('itunes' or 'deezer')"""
try:
return config_manager.get('metadata.fallback_source', 'itunes') or 'itunes'
except Exception:
return 'itunes'
@property
def _fallback(self):
"""Get the active fallback metadata client based on settings"""
if self._fallback_source == 'deezer':
return self._deezer
return self._itunes
def reload_config(self):
"""Reload configuration and re-initialize client"""
self._invalidate_auth_cache()
@ -471,14 +496,14 @@ class SpotifyClient:
def is_authenticated(self) -> bool:
"""
Check if client can service metadata requests.
Returns True if Spotify is authenticated OR iTunes fallback is available.
Returns True if Spotify is authenticated OR fallback (iTunes/Deezer) 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
# Fallback (iTunes or Deezer) is always available — no auth required
return True
def _invalidate_auth_cache(self):
@ -920,24 +945,23 @@ class SpotifyClient:
@rate_limited
def search_tracks(self, query: str, limit: int = 10) -> List[Track]:
"""Search for tracks - falls back to iTunes if Spotify not authenticated"""
"""Search for tracks - falls back to configured metadata source if Spotify not authenticated"""
cache = get_metadata_cache()
use_spotify = self.is_spotify_authenticated()
source = 'spotify' if use_spotify else 'itunes'
# Check search cache
cached_results = cache.get_search_results(source, 'track', query, min(limit, 10))
if cached_results is not None:
tracks = []
for raw in cached_results:
try:
tracks.append(Track.from_spotify_track(raw) if source == 'spotify' else Track.from_itunes_track(raw))
except Exception:
pass
if tracks:
return tracks
if use_spotify:
# Check Spotify cache
cached_results = cache.get_search_results('spotify', 'track', query, min(limit, 10))
if cached_results is not None:
tracks = []
for raw in cached_results:
try:
tracks.append(Track.from_spotify_track(raw))
except Exception:
pass
if tracks:
return tracks
try:
results = self.sp.search(q=query, type='track', limit=min(limit, 10))
tracks = []
@ -958,32 +982,32 @@ class SpotifyClient:
except Exception as e:
logger.error(f"Error searching tracks via Spotify: {e}")
# Fall through to iTunes fallback
# Fall through to fallback
# iTunes fallback
logger.debug(f"Using iTunes fallback for track search: {query}")
return self._itunes.search_tracks(query, limit)
# Fallback (iTunes or Deezer — configured in settings)
logger.debug(f"Using {self._fallback_source} fallback for track search: {query}")
return self._fallback.search_tracks(query, limit)
@rate_limited
def search_artists(self, query: str, limit: int = 10) -> List[Artist]:
"""Search for artists - falls back to iTunes if Spotify not authenticated"""
"""Search for artists - falls back to configured metadata source if Spotify not authenticated"""
cache = get_metadata_cache()
use_spotify = self.is_spotify_authenticated()
source = 'spotify' if use_spotify else 'itunes'
# Check search cache
cached_results = cache.get_search_results(source, 'artist', query, min(limit, 10))
if cached_results is not None:
artists = []
for raw in cached_results:
try:
artists.append(Artist.from_spotify_artist(raw) if source == 'spotify' else Artist.from_itunes_artist(raw))
except Exception:
pass
if artists:
query_lower = query.lower().strip()
artists.sort(key=lambda a: (0 if a.name.lower().strip() == query_lower else 1))
return artists
if use_spotify:
# Check Spotify cache
cached_results = cache.get_search_results('spotify', 'artist', query, min(limit, 10))
if cached_results is not None:
artists = []
for raw in cached_results:
try:
artists.append(Artist.from_spotify_artist(raw))
except Exception:
pass
if artists:
query_lower = query.lower().strip()
artists.sort(key=lambda a: (0 if a.name.lower().strip() == query_lower else 1))
return artists
if use_spotify:
try:
@ -1013,31 +1037,31 @@ class SpotifyClient:
logger.error(f"Error searching artists via Spotify: {e}")
# Fall through to iTunes fallback
# iTunes fallback
logger.debug(f"Using iTunes fallback for artist search: {query}")
artists = self._itunes.search_artists(query, limit)
# Fallback (iTunes or Deezer)
logger.debug(f"Using {self._fallback_source} fallback for artist search: {query}")
artists = self._fallback.search_artists(query, limit)
query_lower = query.lower().strip()
artists.sort(key=lambda a: (0 if a.name.lower().strip() == query_lower else 1))
return artists
@rate_limited
def search_albums(self, query: str, limit: int = 10) -> List[Album]:
"""Search for albums - falls back to iTunes if Spotify not authenticated"""
"""Search for albums - falls back to configured metadata source if Spotify not authenticated"""
cache = get_metadata_cache()
use_spotify = self.is_spotify_authenticated()
source = 'spotify' if use_spotify else 'itunes'
# Check search cache
cached_results = cache.get_search_results(source, 'album', query, min(limit, 10))
if cached_results is not None:
albums = []
for raw in cached_results:
try:
albums.append(Album.from_spotify_album(raw) if source == 'spotify' else Album.from_itunes_album(raw))
except Exception:
pass
if albums:
return albums
if use_spotify:
# Check Spotify cache
cached_results = cache.get_search_results('spotify', 'album', query, min(limit, 10))
if cached_results is not None:
albums = []
for raw in cached_results:
try:
albums.append(Album.from_spotify_album(raw))
except Exception:
pass
if albums:
return albums
if use_spotify:
try:
@ -1062,16 +1086,17 @@ class SpotifyClient:
logger.error(f"Error searching albums via Spotify: {e}")
# Fall through to iTunes fallback
# iTunes fallback
logger.debug(f"Using iTunes fallback for album search: {query}")
return self._itunes.search_albums(query, limit)
# Fallback (iTunes or Deezer)
logger.debug(f"Using {self._fallback_source} fallback for album search: {query}")
return self._fallback.search_albums(query, limit)
@rate_limited
def get_track_details(self, track_id: str) -> Optional[Dict[str, Any]]:
"""Get detailed track information - falls back to iTunes if Spotify not authenticated"""
"""Get detailed track information - falls back to configured metadata source"""
# Check cache — we store raw track_data, reconstruct enhanced on hit
cache = get_metadata_cache()
source = 'itunes' if self._is_itunes_id(track_id) else 'spotify'
fallback_src = self._fallback_source
source = fallback_src if self._is_itunes_id(track_id) else 'spotify'
cached = cache.get_entity(source, 'track', track_id)
if cached:
if source == 'spotify':
@ -1081,8 +1106,8 @@ class SpotifyClient:
# Simplified track cached by get_album_tracks — treat as cache miss
logger.debug(f"Cache hit for track {track_id} lacks album data, fetching full data")
else:
# iTunes cache hit — delegate to iTunesClient which reconstructs enhanced format
return self._itunes.get_track_details(track_id)
# Fallback cache hit — delegate to fallback client which reconstructs enhanced format
return self._fallback.get_track_details(track_id)
if self.is_spotify_authenticated():
try:
@ -1100,13 +1125,13 @@ class SpotifyClient:
logger.error(f"Error fetching track details via Spotify: {e}")
# Fall through to iTunes fallback
# iTunes fallback - only if ID is numeric (iTunes format)
# Fallback - only if ID is numeric (non-Spotify format)
if self._is_itunes_id(track_id):
logger.debug(f"Using iTunes fallback for track details: {track_id}")
result = self._itunes.get_track_details(track_id)
logger.debug(f"Using {fallback_src} fallback for track details: {track_id}")
result = self._fallback.get_track_details(track_id)
return result
else:
logger.debug(f"Cannot use iTunes fallback for Spotify track ID: {track_id}")
logger.debug(f"Cannot use fallback for Spotify track ID: {track_id}")
return None
@staticmethod
@ -1158,10 +1183,11 @@ class SpotifyClient:
@rate_limited
def get_album(self, album_id: str) -> Optional[Dict[str, Any]]:
"""Get album information - falls back to iTunes if Spotify not authenticated"""
"""Get album information - falls back to configured metadata source"""
# Check cache first
cache = get_metadata_cache()
source = 'itunes' if self._is_itunes_id(album_id) else 'spotify'
fallback_src = self._fallback_source
source = fallback_src if self._is_itunes_id(album_id) else 'spotify'
cached = cache.get_entity(source, 'album', album_id)
if cached:
if source == 'spotify':
@ -1171,8 +1197,8 @@ class SpotifyClient:
# Simplified album cached by get_artist_albums — treat as cache miss
logger.debug(f"Cache hit for album {album_id} lacks tracks, fetching full data")
else:
# iTunes cache hit — delegate to iTunesClient which reconstructs Spotify-compatible format
return self._itunes.get_album(album_id)
# Fallback cache hit — delegate to fallback client
return self._fallback.get_album(album_id)
if self.is_spotify_authenticated():
try:
@ -1184,23 +1210,23 @@ class SpotifyClient:
except Exception as e:
_detect_and_set_rate_limit(e, 'get_album')
logger.error(f"Error fetching album via Spotify: {e}")
# Fall through to iTunes fallback
# Fall through to fallback
# iTunes fallback - only if ID is numeric (iTunes format)
# Fallback - only if ID is numeric (non-Spotify format)
if self._is_itunes_id(album_id):
logger.debug(f"Using iTunes fallback for album: {album_id}")
result = self._itunes.get_album(album_id)
return result
logger.debug(f"Using {fallback_src} fallback for album: {album_id}")
return self._fallback.get_album(album_id)
else:
logger.debug(f"Cannot use iTunes fallback for Spotify album ID: {album_id}")
logger.debug(f"Cannot use fallback for Spotify album ID: {album_id}")
return None
@rate_limited
def get_album_tracks(self, album_id: str) -> Optional[Dict[str, Any]]:
"""Get album tracks - falls back to iTunes if Spotify not authenticated"""
"""Get album tracks - falls back to configured metadata source"""
# Cache key uses album_id with '_tracks' suffix to differentiate from album metadata
cache = get_metadata_cache()
source = 'itunes' if self._is_itunes_id(album_id) else 'spotify'
fallback_src = self._fallback_source
source = fallback_src if self._is_itunes_id(album_id) else 'spotify'
cache_key = f"{album_id}_tracks"
cached = cache.get_entity(source, 'album', cache_key)
if cached:
@ -1251,13 +1277,13 @@ class SpotifyClient:
logger.error(f"Error fetching album tracks via Spotify: {e}")
# Fall through to iTunes fallback
# iTunes fallback - only if ID is numeric (iTunes format)
# Fallback - only if ID is numeric (non-Spotify format)
if self._is_itunes_id(album_id):
logger.debug(f"Using iTunes fallback for album tracks: {album_id}")
result = self._itunes.get_album_tracks(album_id)
logger.debug(f"Using {fallback_src} fallback for album tracks: {album_id}")
result = self._fallback.get_album_tracks(album_id)
return result
else:
logger.debug(f"Cannot use iTunes fallback for Spotify album ID: {album_id}")
logger.debug(f"Cannot use fallback for Spotify album ID: {album_id}")
return None
@rate_limited
@ -1293,12 +1319,12 @@ class SpotifyClient:
logger.error(f"Error fetching artist albums via Spotify: {e}")
# Fall through to iTunes fallback
# iTunes fallback - only if ID is numeric (iTunes format)
# Fallback - only if ID is numeric (non-Spotify 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)
logger.debug(f"Using {self._fallback_source} fallback for artist albums: {artist_id}")
return self._fallback.get_artist_albums(artist_id, album_type, limit)
else:
logger.debug(f"Cannot use iTunes fallback for Spotify artist ID: {artist_id}")
logger.debug(f"Cannot use fallback for Spotify artist ID: {artist_id}")
return []
@rate_limited
@ -1315,23 +1341,24 @@ class SpotifyClient:
@rate_limited
def get_artist(self, artist_id: str) -> Optional[Dict[str, Any]]:
"""
Get full artist details - falls back to iTunes if Spotify not authenticated.
Get full artist details - falls back to configured metadata source.
Args:
artist_id: Artist ID (Spotify or iTunes depending on authentication)
artist_id: Artist ID (Spotify or fallback source depending on authentication)
Returns:
Dictionary with artist data including images, genres, popularity
"""
# Check cache first (works even during rate limit bans)
cache = get_metadata_cache()
source = 'itunes' if self._is_itunes_id(artist_id) else 'spotify'
fallback_src = self._fallback_source
source = fallback_src if self._is_itunes_id(artist_id) else 'spotify'
cached = cache.get_entity(source, 'artist', artist_id)
if cached:
if source == 'spotify':
return cached # Spotify raw format is the expected format
# iTunes cache hit — delegate to iTunesClient which reconstructs Spotify-compatible format
return self._itunes.get_artist(artist_id)
# Fallback cache hit — delegate to fallback client which reconstructs Spotify-compatible format
return self._fallback.get_artist(artist_id)
if self.is_spotify_authenticated():
try:
@ -1344,13 +1371,12 @@ class SpotifyClient:
logger.error(f"Error fetching artist via Spotify: {e}")
# Fall through to iTunes fallback
# iTunes fallback - only if ID is numeric (iTunes format)
# Fallback - only if ID is numeric (non-Spotify format)
if self._is_itunes_id(artist_id):
logger.debug(f"Using iTunes fallback for artist: {artist_id}")
result = self._itunes.get_artist(artist_id)
return result
logger.debug(f"Using {fallback_src} fallback for artist: {artist_id}")
return self._fallback.get_artist(artist_id)
else:
logger.debug(f"Cannot use iTunes fallback for Spotify artist ID: {artist_id}")
logger.debug(f"Cannot use fallback for Spotify artist ID: {artist_id}")
return None
@rate_limited

View file

@ -29,6 +29,21 @@ ITUNES_MAX_RETRIES = 3
ITUNES_BASE_DELAY = 1.0 # Base delay in seconds for exponential backoff
def _get_fallback_metadata_client():
"""Get the configured metadata fallback client (iTunes or Deezer)."""
try:
from config.settings import config_manager
source = config_manager.get('metadata.fallback_source', 'itunes') or 'itunes'
if source == 'deezer':
from core.deezer_client import DeezerClient
return DeezerClient(), 'deezer'
from core.itunes_client import iTunesClient
return iTunesClient(), 'itunes'
except Exception:
from core.itunes_client import iTunesClient
return iTunesClient(), 'itunes'
def itunes_api_call_with_retry(func, *args, max_retries=ITUNES_MAX_RETRIES, **kwargs):
"""
Execute an iTunes API call with exponential backoff retry logic.
@ -402,29 +417,38 @@ class WatchlistScanner:
else:
logger.warning(f"No Spotify ID for {watchlist_artist.artist_name}, cannot scan with Spotify")
return (None, None, None)
else: # itunes
if watchlist_artist.itunes_artist_id:
return (self.metadata_service.itunes, watchlist_artist.itunes_artist_id, 'itunes')
else: # itunes or deezer fallback
fallback_source = provider # 'itunes' or 'deezer'
fallback_client = self.metadata_service.itunes # May be iTunesClient or DeezerClient
# Pick the right stored ID for the active fallback source
stored_id = watchlist_artist.deezer_artist_id if fallback_source == 'deezer' else watchlist_artist.itunes_artist_id
if stored_id:
return (fallback_client, stored_id, fallback_source)
else:
# No iTunes ID stored - search by name and cache it
logger.info(f"No iTunes ID for {watchlist_artist.artist_name}, searching by name...")
# No ID stored for this source - search by name and cache it
logger.info(f"No {fallback_source} ID for {watchlist_artist.artist_name}, searching by name...")
try:
itunes_client = self.metadata_service.itunes
search_results = itunes_client.search_artists(watchlist_artist.artist_name, limit=1)
search_results = fallback_client.search_artists(watchlist_artist.artist_name, limit=1)
if search_results and len(search_results) > 0:
itunes_id = search_results[0].id
logger.info(f"Found iTunes ID {itunes_id} for {watchlist_artist.artist_name}")
# Cache the iTunes ID in the database for future use
self.database.update_watchlist_artist_itunes_id(
watchlist_artist.spotify_artist_id or str(watchlist_artist.id),
itunes_id
)
return (itunes_client, itunes_id, 'itunes')
found_id = search_results[0].id
logger.info(f"Found {fallback_source} ID {found_id} for {watchlist_artist.artist_name}")
# Cache the ID in the database for future use
if fallback_source == 'deezer':
self.database.update_watchlist_artist_deezer_id(
watchlist_artist.spotify_artist_id or str(watchlist_artist.id),
found_id
)
else:
self.database.update_watchlist_artist_itunes_id(
watchlist_artist.spotify_artist_id or str(watchlist_artist.id),
found_id
)
return (fallback_client, found_id, fallback_source)
else:
logger.warning(f"Could not find {watchlist_artist.artist_name} on iTunes")
logger.warning(f"Could not find {watchlist_artist.artist_name} on {fallback_source}")
return (None, None, None)
except Exception as e:
logger.error(f"Error searching iTunes for {watchlist_artist.artist_name}: {e}")
logger.error(f"Error searching {fallback_source} for {watchlist_artist.artist_name}: {e}")
return (None, None, None)
def get_active_client_and_artist_id(self, watchlist_artist: WatchlistArtist):
@ -1551,13 +1575,12 @@ class WatchlistScanner:
logger.info(f"Found {len(similar_artist_names)} similar artists from MusicMap")
# Get iTunes client for matching
from core.itunes_client import iTunesClient
itunes_client = iTunesClient()
# Get fallback metadata client for matching (iTunes or Deezer)
itunes_client, fallback_source = _get_fallback_metadata_client()
# Get the searched artist's IDs to exclude them
searched_spotify_id = None
searched_itunes_id = None
searched_fallback_id = None
try:
# Try Spotify search
if self.spotify_client and self.spotify_client.is_spotify_authenticated():
@ -1568,14 +1591,14 @@ class WatchlistScanner:
logger.debug(f"Could not get searched artist Spotify ID: {e}")
try:
# Try iTunes search
itunes_results = itunes_client.search_artists(artist_name, limit=1)
if itunes_results and len(itunes_results) > 0:
searched_itunes_id = itunes_results[0].id
# Try fallback source (iTunes/Deezer) search
fallback_results = itunes_client.search_artists(artist_name, limit=1)
if fallback_results and len(fallback_results) > 0:
searched_fallback_id = fallback_results[0].id
except Exception as e:
logger.debug(f"Could not get searched artist iTunes ID: {e}")
logger.debug(f"Could not get searched artist {fallback_source} ID: {e}")
# Match each artist to both Spotify and iTunes
# Match each artist to both Spotify and fallback source (iTunes/Deezer)
matched_artists = []
seen_names = set() # Track seen artist names to prevent duplicates
@ -1590,6 +1613,7 @@ class WatchlistScanner:
'name': artist_name_to_match,
'spotify_id': None,
'itunes_id': None,
'deezer_id': None,
'image_url': None,
'genres': [],
'popularity': 0
@ -1611,42 +1635,48 @@ class WatchlistScanner:
except Exception as e:
logger.debug(f"Spotify match failed for {artist_name_to_match}: {e}")
# Try to match on iTunes (with retry for rate limiting)
# Try to match on fallback source (iTunes/Deezer) with retry for rate limiting
try:
itunes_results = itunes_api_call_with_retry(
fallback_results = itunes_api_call_with_retry(
itunes_client.search_artists, artist_name_to_match, limit=1
)
if itunes_results and len(itunes_results) > 0:
itunes_artist = itunes_results[0]
if fallback_results and len(fallback_results) > 0:
fallback_artist = fallback_results[0]
# Skip if this is the searched artist
if itunes_artist.id != searched_itunes_id:
artist_data['itunes_id'] = itunes_artist.id
# Use iTunes name if we don't have Spotify
if fallback_artist.id != searched_fallback_id:
# Store under the appropriate key based on fallback source
if fallback_source == 'deezer':
artist_data['deezer_id'] = fallback_artist.id
else:
artist_data['itunes_id'] = fallback_artist.id
# Use fallback name if we don't have Spotify
if not artist_data['spotify_id']:
artist_data['name'] = itunes_artist.name
# Use iTunes genres if we don't have Spotify genres
if not artist_data['genres'] and hasattr(itunes_artist, 'genres'):
artist_data['genres'] = itunes_artist.genres
artist_data['name'] = fallback_artist.name
# Use fallback genres if we don't have Spotify genres
if not artist_data['genres'] and hasattr(fallback_artist, 'genres'):
artist_data['genres'] = fallback_artist.genres
else:
logger.info(f" [iTunes] No match found for: {artist_name_to_match}")
logger.info(f" [{fallback_source}] No match found for: {artist_name_to_match}")
except Exception as e:
logger.info(f" [iTunes] Match failed for {artist_name_to_match}: {e}")
logger.info(f" [{fallback_source}] Match failed for {artist_name_to_match}: {e}")
# Only add if we got at least one ID
if artist_data['spotify_id'] or artist_data['itunes_id']:
fallback_id_key = 'deezer_id' if fallback_source == 'deezer' else 'itunes_id'
if artist_data['spotify_id'] or artist_data.get(fallback_id_key):
seen_names.add(name_lower)
matched_artists.append(artist_data)
logger.debug(f" Matched: {artist_data['name']} (Spotify: {artist_data['spotify_id']}, iTunes: {artist_data['itunes_id']})")
logger.debug(f" Matched: {artist_data['name']} (Spotify: {artist_data['spotify_id']}, {fallback_source}: {artist_data.get(fallback_id_key)})")
except Exception as match_error:
logger.debug(f"Error matching {artist_name_to_match}: {match_error}")
continue
# Log detailed matching statistics
itunes_matched = sum(1 for a in matched_artists if a.get('itunes_id'))
fallback_id_key = 'deezer_id' if fallback_source == 'deezer' else 'itunes_id'
fallback_matched = sum(1 for a in matched_artists if a.get(fallback_id_key))
spotify_matched = sum(1 for a in matched_artists if a.get('spotify_id'))
both_matched = sum(1 for a in matched_artists if a.get('itunes_id') and a.get('spotify_id'))
logger.info(f"Matched {len(matched_artists)} similar artists - iTunes: {itunes_matched}, Spotify: {spotify_matched}, Both: {both_matched}")
both_matched = sum(1 for a in matched_artists if a.get(fallback_id_key) and a.get('spotify_id'))
logger.info(f"Matched {len(matched_artists)} similar artists - {fallback_source}: {fallback_matched}, Spotify: {spotify_matched}, Both: {both_matched}")
return matched_artists
except requests.exceptions.RequestException as e:
@ -1669,40 +1699,42 @@ class WatchlistScanner:
Number of similar artists updated with iTunes IDs
"""
try:
# Get similar artists that are missing iTunes IDs
similar_artists = self.database.get_similar_artists_missing_itunes_ids(source_artist_id, profile_id=profile_id)
# Get fallback metadata client (iTunes or Deezer)
fallback_client, fallback_source = _get_fallback_metadata_client()
# Get similar artists that are missing IDs for the active fallback source
similar_artists = self.database.get_similar_artists_missing_fallback_ids(source_artist_id, fallback_source, profile_id=profile_id)
if not similar_artists:
return 0
logger.info(f"Backfilling iTunes IDs for {len(similar_artists)} similar artists")
# Get iTunes client
from core.itunes_client import iTunesClient
itunes_client = iTunesClient()
logger.info(f"Backfilling {fallback_source} IDs for {len(similar_artists)} similar artists")
updated_count = 0
for similar_artist in similar_artists:
try:
# Search iTunes by artist name
itunes_results = itunes_client.search_artists(similar_artist.similar_artist_name, limit=1)
if itunes_results and len(itunes_results) > 0:
itunes_id = itunes_results[0].id
# Update the similar artist with the iTunes ID
if self.database.update_similar_artist_itunes_id(similar_artist.id, itunes_id):
results = fallback_client.search_artists(similar_artist.similar_artist_name, limit=1)
if results and len(results) > 0:
found_id = results[0].id
# Update the similar artist with the correct source ID
if fallback_source == 'deezer':
success = self.database.update_similar_artist_deezer_id(similar_artist.id, found_id)
else:
success = self.database.update_similar_artist_itunes_id(similar_artist.id, found_id)
if success:
updated_count += 1
logger.debug(f" Backfilled iTunes ID {itunes_id} for {similar_artist.similar_artist_name}")
logger.debug(f" Backfilled {fallback_source} ID {found_id} for {similar_artist.similar_artist_name}")
except Exception as e:
logger.debug(f" Could not backfill iTunes ID for {similar_artist.similar_artist_name}: {e}")
logger.debug(f" Could not backfill {fallback_source} ID for {similar_artist.similar_artist_name}: {e}")
continue
if updated_count > 0:
logger.info(f"Backfilled {updated_count} similar artists with iTunes IDs")
logger.info(f"Backfilled {updated_count} similar artists with {fallback_source} IDs")
return updated_count
except Exception as e:
logger.error(f"Error backfilling similar artists iTunes IDs: {e}")
logger.error(f"Error backfilling similar artists {fallback_source if 'fallback_source' in dir() else 'fallback'} IDs: {e}")
return 0
def update_similar_artists(self, watchlist_artist: WatchlistArtist, limit: int = 10, profile_id: int = 1) -> bool:
@ -1730,7 +1762,7 @@ class WatchlistScanner:
stored_count = 0
for rank, similar_artist in enumerate(similar_artists, 1):
try:
# similar_artist has 'name', 'spotify_id', 'itunes_id', 'image_url', 'genres', 'popularity'
# similar_artist has 'name', 'spotify_id', 'itunes_id', 'deezer_id', 'image_url', 'genres', 'popularity'
success = self.database.add_or_update_similar_artist(
source_artist_id=source_artist_id,
similar_artist_name=similar_artist['name'],
@ -1740,12 +1772,15 @@ class WatchlistScanner:
profile_id=profile_id,
image_url=similar_artist.get('image_url'),
genres=similar_artist.get('genres'),
popularity=similar_artist.get('popularity', 0)
popularity=similar_artist.get('popularity', 0),
similar_artist_deezer_id=similar_artist.get('deezer_id')
)
if success:
stored_count += 1
logger.debug(f" #{rank}: {similar_artist['name']} (Spotify: {similar_artist.get('spotify_id')}, iTunes: {similar_artist.get('itunes_id')})")
fallback_id = similar_artist.get('deezer_id') or similar_artist.get('itunes_id')
fallback_label = 'Deezer' if similar_artist.get('deezer_id') else 'iTunes'
logger.debug(f" #{rank}: {similar_artist['name']} (Spotify: {similar_artist.get('spotify_id')}, {fallback_label}: {fallback_id})")
except Exception as e:
logger.warning(f"Error storing similar artist {similar_artist.get('name', 'Unknown')}: {e}")
@ -1795,16 +1830,15 @@ class WatchlistScanner:
# Determine which sources are available
spotify_available = self.spotify_client and self.spotify_client.is_spotify_authenticated()
# Import iTunes client for fallback
from core.itunes_client import iTunesClient
itunes_client = iTunesClient()
itunes_available = True # iTunes is always available (no auth needed)
# Import fallback metadata client (iTunes or Deezer)
itunes_client, fallback_source = _get_fallback_metadata_client()
fallback_available = True # Fallback source is always available (no auth needed)
if not spotify_available and not itunes_available:
if not spotify_available and not fallback_available:
logger.warning("No music sources available to populate discovery pool")
return
logger.info(f"Sources available - Spotify: {spotify_available}, iTunes: {itunes_available}")
logger.info(f"Sources available - Spotify: {spotify_available}, {fallback_source}: {fallback_available}")
# Get top similar artists for this profile's watchlist (ordered by occurrence_count)
similar_artists = self.database.get_top_similar_artists(limit=top_artists_limit, profile_id=profile_id)
@ -1834,25 +1868,28 @@ class WatchlistScanner:
progress_callback('artist', f'{similar_artist.similar_artist_name} ({artist_idx}/{len(similar_artists)})')
# Build list of sources to process for this artist
# iTunes is ALWAYS processed (baseline), Spotify is added if authenticated
# Fallback source (iTunes/Deezer) is ALWAYS processed (baseline), Spotify is added if authenticated
sources_to_process = []
# Always add iTunes first (baseline source)
itunes_id = similar_artist.similar_artist_itunes_id
if not itunes_id:
# On-the-fly lookup for missing iTunes ID (seamless provider switching)
# Always add fallback source first (baseline source)
fallback_id = similar_artist.similar_artist_itunes_id if fallback_source == 'itunes' else getattr(similar_artist, 'similar_artist_deezer_id', None)
if not fallback_id:
# On-the-fly lookup for missing fallback ID (seamless provider switching)
try:
itunes_results = itunes_client.search_artists(similar_artist.similar_artist_name, limit=1)
if itunes_results and len(itunes_results) > 0:
itunes_id = itunes_results[0].id
fallback_results = itunes_client.search_artists(similar_artist.similar_artist_name, limit=1)
if fallback_results and len(fallback_results) > 0:
fallback_id = fallback_results[0].id
# Cache it for future use
self.database.update_similar_artist_itunes_id(similar_artist.id, itunes_id)
logger.debug(f" Resolved iTunes ID {itunes_id} for {similar_artist.similar_artist_name}")
if fallback_source == 'deezer':
self.database.update_similar_artist_deezer_id(similar_artist.id, fallback_id)
else:
self.database.update_similar_artist_itunes_id(similar_artist.id, fallback_id)
logger.debug(f" Resolved {fallback_source} ID {fallback_id} for {similar_artist.similar_artist_name}")
except Exception as e:
logger.debug(f" Could not resolve iTunes ID for {similar_artist.similar_artist_name}: {e}")
logger.debug(f" Could not resolve {fallback_source} ID for {similar_artist.similar_artist_name}: {e}")
if itunes_id:
sources_to_process.append(('itunes', itunes_id))
if fallback_id:
sources_to_process.append((fallback_source, fallback_id))
# Add Spotify if authenticated and we have an ID
if spotify_available and similar_artist.similar_artist_spotify_id:
@ -1874,7 +1911,7 @@ class WatchlistScanner:
album_type='album,single,ep',
limit=50
)
else: # itunes
else: # itunes or deezer fallback
all_albums = itunes_client.get_artist_albums(
artist_id,
album_type='album,single,ep',
@ -1892,7 +1929,7 @@ class WatchlistScanner:
artist_data = self.spotify_client.get_artist(artist_id)
if artist_data and 'genres' in artist_data:
artist_genres = artist_data['genres']
else: # iTunes - genres from artist lookup
else: # itunes/deezer - genres from artist lookup
artist_data = itunes_client.get_artist(artist_id)
if artist_data and 'genres' in artist_data:
artist_genres = artist_data['genres']
@ -1938,7 +1975,7 @@ class WatchlistScanner:
if not album_data or 'tracks' not in album_data:
continue
tracks = album_data['tracks'].get('items', [])
else: # itunes
else: # itunes or deezer fallback
album_data = itunes_client.get_album(album.id)
if not album_data:
continue
@ -1978,9 +2015,9 @@ class WatchlistScanner:
}
# Build track data for discovery pool with source-specific IDs
# iTunes has no popularity data — synthesize from recency + occurrence
# iTunes/Deezer have no popularity data — synthesize from recency + occurrence
raw_popularity = album_data.get('popularity', 0)
if source == 'itunes' and raw_popularity == 0:
if source in ('itunes', 'deezer') and raw_popularity == 0:
# Base 45, boost by recency and artist occurrence count
synth_pop = 45
if is_new:
@ -2022,6 +2059,10 @@ class WatchlistScanner:
track_data['spotify_track_id'] = track.get('id')
track_data['spotify_album_id'] = album_data.get('id')
track_data['spotify_artist_id'] = similar_artist.similar_artist_spotify_id
elif source == 'deezer':
track_data['deezer_track_id'] = track.get('id')
track_data['deezer_album_id'] = album_data.get('id')
track_data['deezer_artist_id'] = getattr(similar_artist, 'similar_artist_deezer_id', None)
else: # itunes
track_data['itunes_track_id'] = track.get('id')
track_data['itunes_album_id'] = album_data.get('id')
@ -2100,22 +2141,22 @@ class WatchlistScanner:
except Exception as e:
logger.debug(f"Spotify search failed for {album_row['title']}: {e}")
# Fall back to iTunes if Spotify didn't work
if not tracks and itunes_available:
# Fall back to fallback source (iTunes/Deezer) if Spotify didn't work
if not tracks and fallback_available:
try:
search_results = itunes_client.search_albums(query, limit=1)
if search_results and len(search_results) > 0:
itunes_album = search_results[0]
album_data = itunes_client.get_album(itunes_album.id)
fallback_album = search_results[0]
album_data = itunes_client.get_album(fallback_album.id)
if album_data:
tracks_data = itunes_client.get_album_tracks(itunes_album.id)
tracks_data = itunes_client.get_album_tracks(fallback_album.id)
tracks = tracks_data.get('items', []) if tracks_data else []
db_source = 'itunes'
# For iTunes, artist ID is in the album data
db_source = fallback_source
# Artist ID is in the album data
if album_data.get('artists'):
artist_id_for_genres = album_data['artists'][0].get('id')
except Exception as e:
logger.debug(f"iTunes search failed for {album_row['title']}: {e}")
logger.debug(f"{fallback_source} search failed for {album_row['title']}: {e}")
if not tracks or not album_data:
continue
@ -2177,6 +2218,10 @@ class WatchlistScanner:
track_data['spotify_track_id'] = track.get('id')
track_data['spotify_album_id'] = album_data.get('id')
track_data['spotify_artist_id'] = artist_id_for_genres or ''
elif db_source == 'deezer':
track_data['deezer_track_id'] = track.get('id')
track_data['deezer_album_id'] = album_data.get('id')
track_data['deezer_artist_id'] = artist_id_for_genres or ''
else: # itunes
track_data['itunes_track_id'] = track.get('id')
track_data['itunes_album_id'] = album_data.get('id')
@ -2395,24 +2440,23 @@ class WatchlistScanner:
# 30-day window for recent releases
cutoff_date = datetime.now() - timedelta(days=30)
cached_count = {'spotify': 0, 'itunes': 0}
cached_count = {'spotify': 0, 'itunes': 0, 'deezer': 0}
albums_checked = 0
# Determine available sources
spotify_available = self.spotify_client and self.spotify_client.is_spotify_authenticated()
# Get iTunes client
from core.itunes_client import iTunesClient
itunes_client = iTunesClient()
# Get fallback metadata client (iTunes or Deezer)
itunes_client, fallback_source = _get_fallback_metadata_client()
# Get artists to check (scoped to profile)
watchlist_artists = self.database.get_watchlist_artists(profile_id=profile_id)
similar_artists = self.database.get_top_similar_artists(limit=50, profile_id=profile_id)
logger.info(f"Checking albums from {len(watchlist_artists)} watchlist + {len(similar_artists)} similar artists")
logger.info(f"Sources: Spotify={spotify_available}, iTunes=True")
logger.info(f"Sources: Spotify={spotify_available}, {fallback_source}=True")
def process_album(album, artist_name, artist_spotify_id, artist_itunes_id, source):
def process_album(album, artist_name, artist_spotify_id, artist_itunes_id, source, artist_deezer_id=None):
"""Helper to process and cache a single album"""
nonlocal albums_checked
try:
@ -2422,7 +2466,7 @@ class WatchlistScanner:
if not release_str:
return False
# Handle iTunes ISO format (2017-12-08T08:00:00Z)
# Handle iTunes/Deezer ISO format (2017-12-08T08:00:00Z)
if 'T' in release_str:
release_str = release_str.split('T')[0]
@ -2432,10 +2476,12 @@ class WatchlistScanner:
album_data = {
'album_spotify_id': album.id if source == 'spotify' else None,
'album_itunes_id': album.id if source == 'itunes' else None,
'album_deezer_id': album.id if source == 'deezer' else None,
'album_name': album.name,
'artist_name': artist_name,
'artist_spotify_id': artist_spotify_id,
'artist_itunes_id': artist_itunes_id,
'artist_deezer_id': artist_deezer_id,
'album_cover_url': album.image_url if hasattr(album, 'image_url') else None,
'release_date': release_str[:10],
'album_type': album.album_type if hasattr(album, 'album_type') else 'album'
@ -2449,39 +2495,44 @@ class WatchlistScanner:
return False
# Track resolution stats
itunes_resolved = 0
itunes_failed_resolve = 0
fallback_resolved = 0
fallback_failed_resolve = 0
# Process watchlist artists
for artist in watchlist_artists:
# Always process iTunes (baseline)
itunes_id = artist.itunes_artist_id
if not itunes_id:
# Try to resolve iTunes ID on-the-fly (with retry for rate limiting)
# Always process fallback source (iTunes or Deezer) as baseline
fallback_id = artist.itunes_artist_id if fallback_source == 'itunes' else artist.deezer_artist_id
if not fallback_id:
# Try to resolve fallback ID on-the-fly (with retry for rate limiting)
try:
results = itunes_api_call_with_retry(
itunes_client.search_artists, artist.artist_name, limit=1
)
if results and len(results) > 0:
itunes_id = results[0].id
itunes_resolved += 1
logger.debug(f"[iTunes] Resolved ID for {artist.artist_name}: {itunes_id}")
fallback_id = results[0].id
fallback_resolved += 1
logger.debug(f"[{fallback_source}] Resolved ID for {artist.artist_name}: {fallback_id}")
else:
itunes_failed_resolve += 1
logger.info(f"[iTunes] No artist found for: {artist.artist_name}")
fallback_failed_resolve += 1
logger.info(f"[{fallback_source}] No artist found for: {artist.artist_name}")
except Exception as e:
itunes_failed_resolve += 1
logger.info(f"[iTunes] Failed to resolve {artist.artist_name}: {e}")
fallback_failed_resolve += 1
logger.info(f"[{fallback_source}] Failed to resolve {artist.artist_name}: {e}")
if itunes_id:
if fallback_id:
try:
albums = itunes_api_call_with_retry(
itunes_client.get_artist_albums, itunes_id, album_type='album,single,ep', limit=20
itunes_client.get_artist_albums, fallback_id, album_type='album,single,ep', limit=20
)
for album in albums or []:
process_album(album, artist.artist_name, artist.spotify_artist_id, itunes_id, 'itunes')
process_album(
album, artist.artist_name, artist.spotify_artist_id,
fallback_id if fallback_source == 'itunes' else None,
fallback_source,
artist_deezer_id=fallback_id if fallback_source == 'deezer' else None
)
except Exception as e:
logger.info(f"[iTunes] Error fetching albums for {artist.artist_name}: {e}")
logger.info(f"[{fallback_source}] Error fetching albums for {artist.artist_name}: {e}")
# Process Spotify if authenticated
if spotify_available and artist.spotify_artist_id:
@ -2492,7 +2543,7 @@ class WatchlistScanner:
limit=20
)
for album in albums or []:
process_album(album, artist.artist_name, artist.spotify_artist_id, itunes_id, 'spotify')
process_album(album, artist.artist_name, artist.spotify_artist_id, fallback_id if fallback_source == 'itunes' else None, 'spotify')
except Exception as e:
logger.debug(f"Error fetching Spotify albums for {artist.artist_name}: {e}")
@ -2500,36 +2551,44 @@ class WatchlistScanner:
# Process similar artists
for artist in similar_artists:
# Always process iTunes (baseline)
itunes_id = artist.similar_artist_itunes_id
if not itunes_id:
# Try to resolve iTunes ID on-the-fly (with retry for rate limiting)
# Always process fallback source (iTunes or Deezer) as baseline
fallback_id = artist.similar_artist_itunes_id if fallback_source == 'itunes' else getattr(artist, 'similar_artist_deezer_id', None)
if not fallback_id:
# Try to resolve fallback ID on-the-fly (with retry for rate limiting)
try:
results = itunes_api_call_with_retry(
itunes_client.search_artists, artist.similar_artist_name, limit=1
)
if results and len(results) > 0:
itunes_id = results[0].id
fallback_id = results[0].id
# Cache for future
self.database.update_similar_artist_itunes_id(artist.id, itunes_id)
itunes_resolved += 1
logger.debug(f"[iTunes] Resolved ID for similar artist {artist.similar_artist_name}: {itunes_id}")
if fallback_source == 'deezer':
self.database.update_similar_artist_deezer_id(artist.id, fallback_id)
else:
self.database.update_similar_artist_itunes_id(artist.id, fallback_id)
fallback_resolved += 1
logger.debug(f"[{fallback_source}] Resolved ID for similar artist {artist.similar_artist_name}: {fallback_id}")
else:
itunes_failed_resolve += 1
logger.info(f"[iTunes] No artist found for similar: {artist.similar_artist_name}")
fallback_failed_resolve += 1
logger.info(f"[{fallback_source}] No artist found for similar: {artist.similar_artist_name}")
except Exception as e:
itunes_failed_resolve += 1
logger.info(f"[iTunes] Failed to resolve similar {artist.similar_artist_name}: {e}")
fallback_failed_resolve += 1
logger.info(f"[{fallback_source}] Failed to resolve similar {artist.similar_artist_name}: {e}")
if itunes_id:
if fallback_id:
try:
albums = itunes_api_call_with_retry(
itunes_client.get_artist_albums, itunes_id, album_type='album,single,ep', limit=20
itunes_client.get_artist_albums, fallback_id, album_type='album,single,ep', limit=20
)
for album in albums or []:
process_album(album, artist.similar_artist_name, artist.similar_artist_spotify_id, itunes_id, 'itunes')
process_album(
album, artist.similar_artist_name, artist.similar_artist_spotify_id,
fallback_id if fallback_source == 'itunes' else None,
fallback_source,
artist_deezer_id=fallback_id if fallback_source == 'deezer' else None
)
except Exception as e:
logger.info(f"[iTunes] Error fetching albums for similar {artist.similar_artist_name}: {e}")
logger.info(f"[{fallback_source}] Error fetching albums for similar {artist.similar_artist_name}: {e}")
# Process Spotify if authenticated
if spotify_available and artist.similar_artist_spotify_id:
@ -2540,15 +2599,15 @@ class WatchlistScanner:
limit=20
)
for album in albums or []:
process_album(album, artist.similar_artist_name, artist.similar_artist_spotify_id, itunes_id, 'spotify')
process_album(album, artist.similar_artist_name, artist.similar_artist_spotify_id, fallback_id if fallback_source == 'itunes' else None, 'spotify')
except Exception as e:
logger.debug(f"Error fetching Spotify albums for {artist.similar_artist_name}: {e}")
time.sleep(DELAY_BETWEEN_ARTISTS)
total_cached = cached_count['spotify'] + cached_count['itunes']
logger.info(f"Cached {total_cached} recent albums (Spotify: {cached_count['spotify']}, iTunes: {cached_count['itunes']}) from {albums_checked} albums checked")
logger.info(f"[iTunes] ID resolution stats: {itunes_resolved} resolved, {itunes_failed_resolve} failed")
total_cached = cached_count['spotify'] + cached_count.get(fallback_source, 0)
logger.info(f"Cached {total_cached} recent albums (Spotify: {cached_count['spotify']}, {fallback_source}: {cached_count.get(fallback_source, 0)}) from {albums_checked} albums checked")
logger.info(f"[{fallback_source}] ID resolution stats: {fallback_resolved} resolved, {fallback_failed_resolve} failed")
except Exception as e:
logger.error(f"Error caching discovery recent albums: {e}")
@ -2566,16 +2625,15 @@ class WatchlistScanner:
try:
import random
from datetime import datetime
from core.itunes_client import iTunesClient
logger.info("Curating discovery playlists...")
# Determine available sources
spotify_available = self.spotify_client and self.spotify_client.is_spotify_authenticated()
itunes_client = iTunesClient()
itunes_client, fallback_source = _get_fallback_metadata_client()
# Process each available source
sources_to_process = ['itunes'] # iTunes always available
sources_to_process = [fallback_source] # Fallback source (iTunes/Deezer) always available
if spotify_available:
sources_to_process.append('spotify')
@ -2609,7 +2667,12 @@ class WatchlistScanner:
for album in albums:
try:
# Get album data from appropriate source
album_id = album.get('album_spotify_id') if source == 'spotify' else album.get('album_itunes_id')
if source == 'spotify':
album_id = album.get('album_spotify_id')
elif source == 'deezer':
album_id = album.get('album_deezer_id')
else:
album_id = album.get('album_itunes_id')
if not album_id:
continue
@ -2641,8 +2704,8 @@ class WatchlistScanner:
# Calculate track score
recency_score = max(0, 100 - (days_old * 7))
popularity_score = track.get('popularity', album_data.get('popularity', 0))
# iTunes has no popularity — use recency-based synthetic score
if source == 'itunes' and popularity_score == 0:
# iTunes/Deezer have no popularity — use recency-based synthetic score
if source in ('itunes', 'deezer') and popularity_score == 0:
popularity_score = max(40, 70 - days_old)
is_single = album.get('album_type', 'album') == 'single'
single_bonus = 20 if is_single else 0
@ -2703,6 +2766,9 @@ class WatchlistScanner:
if source == 'spotify':
formatted_track['spotify_track_id'] = track_data['id']
formatted_track['spotify_album_id'] = track_data['album'].get('id', '')
elif source == 'deezer':
formatted_track['deezer_track_id'] = track_data['id']
formatted_track['deezer_album_id'] = track_data['album'].get('id', '')
else:
formatted_track['itunes_track_id'] = track_data['id']
formatted_track['itunes_album_id'] = track_data['album'].get('id', '')
@ -2758,13 +2824,15 @@ class WatchlistScanner:
discovery_weekly_tracks.append(track.spotify_track_id)
elif source == 'itunes' and track.itunes_track_id:
discovery_weekly_tracks.append(track.itunes_track_id)
elif source == 'deezer' and track.deezer_track_id:
discovery_weekly_tracks.append(track.deezer_track_id)
playlist_key = f'discovery_weekly_{source}'
self.database.save_curated_playlist(playlist_key, discovery_weekly_tracks, profile_id=profile_id)
logger.info(f"Discovery Weekly ({source}) curated: {len(discovery_weekly_tracks)} tracks")
# Also save without suffix for backward compatibility (use active source)
active_source = 'spotify' if spotify_available else 'itunes'
active_source = 'spotify' if spotify_available else fallback_source
release_radar_key = f'release_radar_{active_source}'
discovery_weekly_key = f'discovery_weekly_{active_source}'

View file

@ -87,6 +87,7 @@ class WatchlistArtist:
updated_at: Optional[datetime] = None
image_url: Optional[str] = None
itunes_artist_id: Optional[str] = None # Cross-provider support
deezer_artist_id: Optional[str] = None # Cross-provider support
include_albums: bool = True
include_eps: bool = True
include_singles: bool = True
@ -99,7 +100,7 @@ class WatchlistArtist:
@dataclass
class SimilarArtist:
"""Similar artist recommendation from Spotify/iTunes"""
"""Similar artist recommendation from Spotify/iTunes/Deezer"""
id: int
source_artist_id: str # Watchlist artist's database ID
similar_artist_spotify_id: Optional[str] # Spotify artist ID (may be None if iTunes-only)
@ -111,6 +112,7 @@ class SimilarArtist:
image_url: Optional[str] = None # Cached artist image
genres: Optional[List[str]] = None # Cached genres
popularity: int = 0 # Cached popularity score
similar_artist_deezer_id: Optional[str] = None # Deezer artist ID
@dataclass
class DiscoveryTrack:
@ -122,7 +124,10 @@ class DiscoveryTrack:
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'
deezer_track_id: Optional[str] # Deezer track ID (None if non-Deezer source)
deezer_album_id: Optional[str] # Deezer album ID (None if non-Deezer source)
deezer_artist_id: Optional[str] # Deezer artist ID (None if non-Deezer source)
source: str # 'spotify', 'itunes', or 'deezer'
track_name: str
artist_name: str
album_name: str
@ -141,7 +146,8 @@ class RecentRelease:
watchlist_artist_id: int
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_deezer_id: Optional[str] # Deezer album ID (None if non-Deezer source)
source: str # 'spotify', 'itunes', or 'deezer'
album_name: str
release_date: str
album_cover_url: Optional[str]
@ -846,6 +852,13 @@ class MusicDatabase:
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 Deezer columns to discovery_pool for tri-source discovery
if 'deezer_track_id' not in discovery_pool_columns:
cursor.execute("ALTER TABLE discovery_pool ADD COLUMN deezer_track_id TEXT")
cursor.execute("ALTER TABLE discovery_pool ADD COLUMN deezer_album_id TEXT")
cursor.execute("ALTER TABLE discovery_pool ADD COLUMN deezer_artist_id TEXT")
logger.info("Added Deezer columns to discovery_pool table")
# 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()]
@ -854,6 +867,10 @@ class MusicDatabase:
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")
if 'similar_artist_deezer_id' not in similar_artists_columns:
cursor.execute("ALTER TABLE similar_artists ADD COLUMN similar_artist_deezer_id TEXT")
logger.info("Added similar_artist_deezer_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()]
@ -863,6 +880,11 @@ class MusicDatabase:
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 Deezer column to recent_releases for tri-source discovery
if 'album_deezer_id' not in recent_releases_columns:
cursor.execute("ALTER TABLE recent_releases ADD COLUMN album_deezer_id TEXT")
logger.info("Added album_deezer_id column to recent_releases table")
# 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()]
@ -873,6 +895,12 @@ class MusicDatabase:
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: Add Deezer columns to discovery_recent_albums for tri-source discovery
if 'album_deezer_id' not in discovery_recent_albums_columns:
cursor.execute("ALTER TABLE discovery_recent_albums ADD COLUMN album_deezer_id TEXT")
cursor.execute("ALTER TABLE discovery_recent_albums ADD COLUMN artist_deezer_id TEXT")
logger.info("Added Deezer columns to discovery_recent_albums table")
# 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'")
@ -973,6 +1001,8 @@ class MusicDatabase:
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_deezer_track ON discovery_pool (deezer_track_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_deezer_artist ON discovery_pool (deezer_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)")
@ -1099,6 +1129,10 @@ class MusicDatabase:
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")
if 'deezer_artist_id' not in columns:
cursor.execute("ALTER TABLE watchlist_artists ADD COLUMN deezer_artist_id TEXT")
logger.info("Added deezer_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
@ -5428,28 +5462,63 @@ class MusicDatabase:
return 0
# Watchlist operations
def add_artist_to_watchlist(self, artist_id: str, artist_name: str, profile_id: int = 1) -> bool:
def add_artist_to_watchlist(self, artist_id: str, artist_name: str, profile_id: int = 1, source: str = None) -> bool:
"""Add an artist to the watchlist for monitoring new releases.
Automatically detects if artist_id is a Spotify ID (alphanumeric) or iTunes ID (numeric).
Automatically detects if artist_id is a Spotify ID (alphanumeric) or iTunes/Deezer ID (numeric).
If the artist already exists (by name match), updates the existing row with the new source ID.
"""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
# Detect ID type: iTunes IDs are purely numeric, Spotify IDs are alphanumeric
is_itunes_id = artist_id.isdigit()
# Check if artist already exists by name (case-insensitive) for this profile
cursor.execute("""
SELECT id, spotify_artist_id, itunes_artist_id, deezer_artist_id
FROM watchlist_artists
WHERE LOWER(artist_name) = LOWER(?) AND profile_id = ?
LIMIT 1
""", (artist_name, profile_id))
existing = cursor.fetchone()
if is_itunes_id:
# Detect source: explicit source param, or infer from ID format
if not source:
source = 'itunes' if artist_id.isdigit() else 'spotify'
if existing:
# Artist already on watchlist — update with new source ID if missing
col_map = {'spotify': 'spotify_artist_id', 'itunes': 'itunes_artist_id', 'deezer': 'deezer_artist_id'}
col = col_map.get(source)
if col and not existing[col]:
cursor.execute(f"""
UPDATE watchlist_artists
SET {col} = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""", (artist_id, existing['id']))
conn.commit()
logger.info(f"Updated existing watchlist artist '{artist_name}' with {source} ID: {artist_id}")
else:
logger.info(f"Artist '{artist_name}' already on watchlist (profile: {profile_id})")
return True
# New artist — insert with the appropriate ID column
if source == 'deezer':
cursor.execute("""
INSERT OR REPLACE INTO watchlist_artists
INSERT INTO watchlist_artists
(deezer_artist_id, artist_name, date_added, updated_at, profile_id)
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?)
""", (artist_id, artist_name, profile_id))
logger.info(f"Added artist '{artist_name}' to watchlist (Deezer ID: {artist_id}, profile: {profile_id})")
elif source == 'itunes':
cursor.execute("""
INSERT INTO watchlist_artists
(itunes_artist_id, artist_name, date_added, updated_at, profile_id)
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?)
""", (artist_id, artist_name, profile_id))
logger.info(f"Added artist '{artist_name}' to watchlist (iTunes ID: {artist_id}, profile: {profile_id})")
else:
cursor.execute("""
INSERT OR REPLACE INTO watchlist_artists
INSERT INTO watchlist_artists
(spotify_artist_id, artist_name, date_added, updated_at, profile_id)
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?)
""", (artist_id, artist_name, profile_id))
@ -5463,23 +5532,23 @@ class MusicDatabase:
return False
def remove_artist_from_watchlist(self, artist_id: str, profile_id: int = 1) -> bool:
"""Remove an artist from the watchlist (checks both Spotify and iTunes IDs)"""
"""Remove an artist from the watchlist (checks Spotify, iTunes, and Deezer IDs)"""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
# Get artist name for logging (check both ID columns)
# Get artist name for logging (check all ID columns)
cursor.execute("""
SELECT artist_name FROM watchlist_artists
WHERE (spotify_artist_id = ? OR itunes_artist_id = ?) AND profile_id = ?
""", (artist_id, artist_id, profile_id))
WHERE (spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ?) AND profile_id = ?
""", (artist_id, artist_id, artist_id, profile_id))
result = cursor.fetchone()
artist_name = result['artist_name'] if result else "Unknown"
cursor.execute("""
DELETE FROM watchlist_artists
WHERE (spotify_artist_id = ? OR itunes_artist_id = ?) AND profile_id = ?
""", (artist_id, artist_id, profile_id))
WHERE (spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ?) AND profile_id = ?
""", (artist_id, artist_id, artist_id, profile_id))
if cursor.rowcount > 0:
conn.commit()
@ -5493,18 +5562,25 @@ class MusicDatabase:
logger.error(f"Error removing artist from watchlist (ID: {artist_id}): {e}")
return False
def is_artist_in_watchlist(self, artist_id: str, profile_id: int = 1) -> bool:
"""Check if an artist is currently in the watchlist (checks both Spotify and iTunes IDs)"""
def is_artist_in_watchlist(self, artist_id: str, profile_id: int = 1, artist_name: str = None) -> bool:
"""Check if an artist is currently in the watchlist (checks Spotify, iTunes, Deezer IDs and name)"""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
# 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 = ?) AND profile_id = ?
LIMIT 1
""", (artist_id, artist_id, profile_id))
# Check all ID columns and optionally artist name
if artist_name:
cursor.execute("""
SELECT 1 FROM watchlist_artists
WHERE (spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ? OR LOWER(artist_name) = LOWER(?)) AND profile_id = ?
LIMIT 1
""", (artist_id, artist_id, artist_id, artist_name, profile_id))
else:
cursor.execute("""
SELECT 1 FROM watchlist_artists
WHERE (spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ?) AND profile_id = ?
LIMIT 1
""", (artist_id, artist_id, artist_id, profile_id))
result = cursor.fetchone()
return result is not None
@ -5526,7 +5602,7 @@ class MusicDatabase:
# Build SELECT query based on existing columns
base_columns = ['id', 'spotify_artist_id', 'artist_name', 'date_added',
'last_scan_timestamp', 'created_at', 'updated_at']
optional_columns = ['image_url', 'itunes_artist_id', 'include_albums', 'include_eps', 'include_singles',
optional_columns = ['image_url', 'itunes_artist_id', 'deezer_artist_id', 'include_albums', 'include_eps', 'include_singles',
'include_live', 'include_remixes', 'include_acoustic', 'include_compilations',
'include_instrumentals']
@ -5553,6 +5629,7 @@ class MusicDatabase:
# 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
itunes_artist_id = row['itunes_artist_id'] if 'itunes_artist_id' in existing_columns else None
deezer_artist_id = row['deezer_artist_id'] if 'deezer_artist_id' in existing_columns else None
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_singles = bool(row['include_singles']) if 'include_singles' in existing_columns else True
@ -5572,6 +5649,7 @@ class MusicDatabase:
updated_at=datetime.fromisoformat(row['updated_at']) if row['updated_at'] else None,
image_url=image_url,
itunes_artist_id=itunes_artist_id,
deezer_artist_id=deezer_artist_id,
include_albums=include_albums,
include_eps=include_eps,
include_singles=include_singles,
@ -5840,6 +5918,27 @@ class MusicDatabase:
logger.error(f"Error caching watchlist iTunes ID: {e}")
return False
def update_watchlist_artist_deezer_id(self, spotify_artist_id: str, deezer_id: str) -> bool:
"""Update the Deezer 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 deezer_artist_id = ?, updated_at = CURRENT_TIMESTAMP
WHERE spotify_artist_id = ?
""", (deezer_id, spotify_artist_id))
conn.commit()
if cursor.rowcount > 0:
logger.info(f"Cached Deezer ID {deezer_id} for Spotify artist {spotify_artist_id}")
return cursor.rowcount > 0
except Exception as e:
logger.error(f"Error caching watchlist Deezer ID: {e}")
return False
# === Discovery Feature Methods ===
def add_or_update_similar_artist(self, source_artist_id: str, similar_artist_name: str,
@ -5849,8 +5948,9 @@ class MusicDatabase:
profile_id: int = 1,
image_url: Optional[str] = None,
genres: Optional[list] = None,
popularity: int = 0) -> bool:
"""Add or update a similar artist recommendation (supports both Spotify and iTunes IDs)"""
popularity: int = 0,
similar_artist_deezer_id: Optional[str] = None) -> bool:
"""Add or update a similar artist recommendation (supports Spotify, iTunes, and Deezer IDs)"""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
@ -5859,14 +5959,15 @@ class MusicDatabase:
# Use artist name as the unique key (allows storing both IDs for same artist)
cursor.execute("""
INSERT INTO similar_artists
(source_artist_id, similar_artist_spotify_id, similar_artist_itunes_id, similar_artist_name,
(source_artist_id, similar_artist_spotify_id, similar_artist_itunes_id, similar_artist_deezer_id, similar_artist_name,
similarity_rank, occurrence_count, last_updated, profile_id,
image_url, genres, popularity, metadata_updated_at)
VALUES (?, ?, ?, ?, ?, 1, CURRENT_TIMESTAMP, ?, ?, ?, ?, CURRENT_TIMESTAMP)
VALUES (?, ?, ?, ?, ?, ?, 1, CURRENT_TIMESTAMP, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(profile_id, source_artist_id, similar_artist_name)
DO UPDATE SET
similar_artist_spotify_id = COALESCE(excluded.similar_artist_spotify_id, similar_artist_spotify_id),
similar_artist_itunes_id = COALESCE(excluded.similar_artist_itunes_id, similar_artist_itunes_id),
similar_artist_deezer_id = COALESCE(excluded.similar_artist_deezer_id, similar_artist_deezer_id),
similarity_rank = excluded.similarity_rank,
occurrence_count = occurrence_count + 1,
last_updated = CURRENT_TIMESTAMP,
@ -5874,7 +5975,7 @@ class MusicDatabase:
genres = COALESCE(excluded.genres, genres),
popularity = CASE WHEN excluded.popularity > 0 THEN excluded.popularity ELSE popularity END,
metadata_updated_at = CASE WHEN excluded.image_url IS NOT NULL THEN CURRENT_TIMESTAMP ELSE metadata_updated_at END
""", (source_artist_id, similar_artist_spotify_id, similar_artist_itunes_id, similar_artist_name,
""", (source_artist_id, similar_artist_spotify_id, similar_artist_itunes_id, similar_artist_deezer_id, similar_artist_name,
similarity_rank, profile_id, image_url, genres_json, popularity))
conn.commit()
@ -5905,7 +6006,8 @@ class MusicDatabase:
similar_artist_name=row['similar_artist_name'],
similarity_rank=row['similarity_rank'],
occurrence_count=row['occurrence_count'],
last_updated=datetime.fromisoformat(row['last_updated'])
last_updated=datetime.fromisoformat(row['last_updated']),
similar_artist_deezer_id=row['similar_artist_deezer_id'] if 'similar_artist_deezer_id' in row.keys() else None,
) for row in rows]
except Exception as e:
@ -5914,14 +6016,19 @@ class MusicDatabase:
def get_similar_artists_missing_itunes_ids(self, source_artist_id: str, profile_id: int = 1) -> List[SimilarArtist]:
"""Get similar artists for a source that are missing iTunes IDs (for backfill)"""
return self.get_similar_artists_missing_fallback_ids(source_artist_id, 'itunes', profile_id)
def get_similar_artists_missing_fallback_ids(self, source_artist_id: str, fallback_source: str = 'itunes', profile_id: int = 1) -> List[SimilarArtist]:
"""Get similar artists missing IDs for the given fallback source (for backfill)"""
try:
col = 'similar_artist_deezer_id' if fallback_source == 'deezer' else 'similar_artist_itunes_id'
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
cursor.execute(f"""
SELECT * FROM similar_artists
WHERE source_artist_id = ? AND profile_id = ?
AND (similar_artist_itunes_id IS NULL OR similar_artist_itunes_id = '')
AND ({col} IS NULL OR {col} = '')
ORDER BY occurrence_count DESC
LIMIT 50
""", (source_artist_id, profile_id))
@ -5931,15 +6038,16 @@ class MusicDatabase:
id=row['id'],
source_artist_id=row['source_artist_id'],
similar_artist_spotify_id=row['similar_artist_spotify_id'],
similar_artist_itunes_id=None,
similar_artist_itunes_id=row['similar_artist_itunes_id'] if 'similar_artist_itunes_id' in row.keys() else None,
similar_artist_name=row['similar_artist_name'],
similarity_rank=row['similarity_rank'],
occurrence_count=row['occurrence_count'],
last_updated=datetime.fromisoformat(row['last_updated'])
last_updated=datetime.fromisoformat(row['last_updated']),
similar_artist_deezer_id=row['similar_artist_deezer_id'] if 'similar_artist_deezer_id' in row.keys() else None,
) for row in rows]
except Exception as e:
logger.error(f"Error getting similar artists missing iTunes IDs: {e}")
logger.error(f"Error getting similar artists missing {fallback_source} IDs: {e}")
return []
def update_similar_artist_itunes_id(self, similar_artist_id: int, itunes_id: str) -> bool:
@ -5961,6 +6069,25 @@ class MusicDatabase:
logger.error(f"Error updating similar artist iTunes ID: {e}")
return False
def update_similar_artist_deezer_id(self, similar_artist_id: int, deezer_id: str) -> bool:
"""Update a similar artist's Deezer ID (for backfill)"""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE similar_artists
SET similar_artist_deezer_id = ?
WHERE id = ?
""", (deezer_id, similar_artist_id))
conn.commit()
return cursor.rowcount > 0
except Exception as e:
logger.error(f"Error updating similar artist Deezer ID: {e}")
return False
def update_similar_artist_metadata(self, similar_artist_id: int, image_url: str = None,
genres: list = None, popularity: int = None) -> bool:
"""Cache artist metadata (image, genres, popularity) to avoid repeated API calls"""
@ -5989,6 +6116,8 @@ class MusicDatabase:
genres_json = json.dumps(genres) if genres else None
if source == 'spotify':
where_clause = "similar_artist_spotify_id = ?"
elif source == 'deezer':
where_clause = "similar_artist_deezer_id = ?"
else:
where_clause = "similar_artist_itunes_id = ?"
cursor.execute(f"""
@ -6091,6 +6220,7 @@ class MusicDatabase:
MAX(sa.source_artist_id) as source_artist_id,
MAX(sa.similar_artist_spotify_id) as similar_artist_spotify_id,
MAX(sa.similar_artist_itunes_id) as similar_artist_itunes_id,
MAX(sa.similar_artist_deezer_id) as similar_artist_deezer_id,
sa.similar_artist_name,
AVG(sa.similarity_rank) as similarity_rank,
SUM(sa.occurrence_count) as occurrence_count,
@ -6102,6 +6232,7 @@ class MusicDatabase:
LEFT JOIN watchlist_artists wa ON (
(sa.similar_artist_spotify_id IS NOT NULL AND sa.similar_artist_spotify_id = wa.spotify_artist_id)
OR (sa.similar_artist_itunes_id IS NOT NULL AND sa.similar_artist_itunes_id = wa.itunes_artist_id)
OR (sa.similar_artist_deezer_id IS NOT NULL AND sa.similar_artist_deezer_id = wa.deezer_artist_id)
OR LOWER(sa.similar_artist_name) = LOWER(wa.artist_name)
) AND wa.profile_id = ?
WHERE wa.id IS NULL AND sa.profile_id = ?
@ -6127,6 +6258,7 @@ class MusicDatabase:
source_artist_id=row['source_artist_id'],
similar_artist_spotify_id=row['similar_artist_spotify_id'],
similar_artist_itunes_id=row['similar_artist_itunes_id'] if 'similar_artist_itunes_id' in row.keys() else None,
similar_artist_deezer_id=row['similar_artist_deezer_id'] if 'similar_artist_deezer_id' in row.keys() else None,
similar_artist_name=row['similar_artist_name'],
similarity_rank=int(row['similarity_rank']),
occurrence_count=row['occurrence_count'],
@ -6171,6 +6303,9 @@ class MusicDatabase:
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' AND profile_id = ?",
(track_data['itunes_track_id'], profile_id))
elif source == 'deezer' and track_data.get('deezer_track_id'):
cursor.execute("SELECT COUNT(*) as count FROM discovery_pool WHERE deezer_track_id = ? AND source = 'deezer' AND profile_id = ?",
(track_data['deezer_track_id'], profile_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 = ? AND profile_id = ?",
@ -6187,9 +6322,10 @@ class MusicDatabase:
INSERT INTO discovery_pool
(spotify_track_id, spotify_album_id, spotify_artist_id,
itunes_track_id, itunes_album_id, itunes_artist_id,
deezer_track_id, deezer_album_id, deezer_artist_id,
source, track_name, artist_name, album_name, album_cover_url,
duration_ms, popularity, release_date, is_new_release, track_data_json, artist_genres, added_date, profile_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?)
""", (
track_data.get('spotify_track_id'),
track_data.get('spotify_album_id'),
@ -6197,6 +6333,9 @@ class MusicDatabase:
track_data.get('itunes_track_id'),
track_data.get('itunes_album_id'),
track_data.get('itunes_artist_id'),
track_data.get('deezer_track_id'),
track_data.get('deezer_album_id'),
track_data.get('deezer_artist_id'),
source,
track_data['track_name'],
track_data['artist_name'],
@ -6284,6 +6423,9 @@ class MusicDatabase:
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,
deezer_track_id=row['deezer_track_id'] if 'deezer_track_id' in row_keys else None,
deezer_album_id=row['deezer_album_id'] if 'deezer_album_id' in row_keys else None,
deezer_artist_id=row['deezer_artist_id'] if 'deezer_artist_id' in row_keys else None,
source=row['source'] if 'source' in row_keys else 'spotify',
track_name=row['track_name'],
artist_name=row['artist_name'],
@ -6309,14 +6451,17 @@ class MusicDatabase:
cursor.execute("""
INSERT OR REPLACE INTO discovery_recent_albums
(album_spotify_id, album_itunes_id, artist_spotify_id, artist_itunes_id, source,
(album_spotify_id, album_itunes_id, album_deezer_id,
artist_spotify_id, artist_itunes_id, artist_deezer_id, source,
album_name, artist_name, album_cover_url, release_date, album_type, cached_date, profile_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?)
""", (
album_data.get('album_spotify_id'),
album_data.get('album_itunes_id'),
album_data.get('album_deezer_id'),
album_data.get('artist_spotify_id'),
album_data.get('artist_itunes_id'),
album_data.get('artist_deezer_id'),
source,
album_data['album_name'],
album_data['artist_name'],
@ -6539,6 +6684,7 @@ class MusicDatabase:
watchlist_artist_id=row['watchlist_artist_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_deezer_id=row['album_deezer_id'] if 'album_deezer_id' in row.keys() else None,
source=row['source'] if 'source' in row.keys() else 'spotify',
album_name=row['album_name'],
release_date=row['release_date'],

View file

@ -3147,11 +3147,12 @@ def run_service_test(service, test_config):
if temp_client.is_spotify_authenticated():
return True, "Spotify connection successful!"
else:
# Using iTunes fallback
# Using fallback metadata source
fallback_name = 'Deezer' if _get_metadata_fallback_source() == 'deezer' else 'Apple Music'
if spotify_configured:
return True, "Apple Music connection successful! (Spotify configured but not authenticated)"
return True, f"{fallback_name} connection successful! (Spotify configured but not authenticated)"
else:
return True, "Apple Music connection successful! (Spotify not configured)"
return True, f"{fallback_name} connection successful! (Spotify not configured)"
else:
return False, "Music service authentication failed. Check credentials and complete OAuth flow in browser if prompted."
elif service == "tidal":
@ -3647,13 +3648,13 @@ def get_status():
if is_rate_limited or cooldown_remaining > 0:
# During rate limit or post-ban cooldown, skip the auth probe entirely.
# Probing Spotify here would reset the rate limit timer.
music_source = 'itunes' # App uses iTunes during ban — reflect truth
music_source = _get_metadata_fallback_source() # App uses fallback during ban
spotify_response_time = 0
else:
spotify_start = time.time()
spotify_connected = spotify_client.is_spotify_authenticated() if spotify_client else False
spotify_response_time = (time.time() - spotify_start) * 1000
music_source = 'spotify' if spotify_connected else 'itunes'
music_source = 'spotify' if spotify_connected else _get_metadata_fallback_source()
_status_cache['spotify'] = {
'connected': True, # Always true — iTunes fallback is always available
@ -4253,7 +4254,7 @@ def handle_settings():
if 'active_media_server' in new_settings:
config_manager.set_active_media_server(new_settings['active_media_server'])
for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'tidal_download', 'qobuz', 'hifi_download', 'listenbrainz', 'acoustid', 'lastfm', 'genius', 'import', 'lossy_copy', 'ui_appearance', 'youtube', 'content_filter', 'itunes', 'm3u_export', 'musicbrainz', 'deezer', 'audiodb']:
for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'tidal_download', 'qobuz', 'hifi_download', 'listenbrainz', 'acoustid', 'lastfm', 'genius', 'import', 'lossy_copy', 'ui_appearance', 'youtube', 'content_filter', 'itunes', 'm3u_export', 'musicbrainz', 'deezer', 'audiodb', 'metadata']:
if service in new_settings:
for key, value in new_settings[service].items():
config_manager.set(f'{service}.{key}', value)
@ -4294,6 +4295,8 @@ def handle_settings():
genius_worker._init_client()
if tidal_enrichment_worker:
tidal_enrichment_worker.client = tidal_client
# Invalidate status cache so next poll reflects new settings (e.g. fallback source change)
_status_cache_timestamps['spotify'] = 0
print("✅ Service clients re-initialized with new settings.")
return jsonify({"success": True, "message": "Settings saved successfully."})
except Exception as e:
@ -5101,7 +5104,7 @@ def test_connection_endpoint():
current_time = time.time()
if service == 'spotify':
_status_cache['spotify']['connected'] = True
_status_cache['spotify']['source'] = 'spotify' if (spotify_client and spotify_client.is_spotify_authenticated()) else 'itunes'
_status_cache['spotify']['source'] = 'spotify' if (spotify_client and spotify_client.is_spotify_authenticated()) else _get_metadata_fallback_source()
_status_cache_timestamps['spotify'] = current_time
print("✅ Updated Spotify status cache after successful test")
elif service in ['plex', 'jellyfin', 'navidrome']:
@ -5151,7 +5154,7 @@ def test_dashboard_connection_endpoint():
current_time = time.time()
if service == 'spotify':
_status_cache['spotify']['connected'] = True
_status_cache['spotify']['source'] = 'spotify' if (spotify_client and spotify_client.is_spotify_authenticated()) else 'itunes'
_status_cache['spotify']['source'] = 'spotify' if (spotify_client and spotify_client.is_spotify_authenticated()) else _get_metadata_fallback_source()
_status_cache_timestamps['spotify'] = current_time
print("✅ Updated Spotify status cache after successful dashboard test")
elif service in ['plex', 'jellyfin', 'navidrome']:
@ -5819,16 +5822,18 @@ def spotify_disconnect():
spotify_enrichment_worker.pause()
spotify_client.disconnect()
# Immediately update status cache so UI reflects the change
fallback_src = _get_metadata_fallback_source()
_status_cache['spotify'] = {
'connected': True, # iTunes fallback is always available
'connected': True, # Fallback source is always available
'response_time': 0,
'source': 'itunes',
'source': fallback_src,
'rate_limited': False,
'rate_limit': None
}
_status_cache_timestamps['spotify'] = time.time()
add_activity_item("🔌", "Spotify Disconnected", "Switched to Apple Music/iTunes metadata source", "Now")
return jsonify({'success': True, 'message': 'Spotify disconnected. Now using Apple Music/iTunes.'})
fallback_label = 'Deezer' if fallback_src == 'deezer' else 'Apple Music/iTunes'
add_activity_item("🔌", "Spotify Disconnected", f"Switched to {fallback_label} metadata source", "Now")
return jsonify({'success': True, 'message': f'Spotify disconnected. Now using {fallback_label}.'})
except Exception as e:
logger.error(f"Error disconnecting Spotify: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@ -8462,10 +8467,9 @@ def get_artist_image(artist_id):
return jsonify({"success": True, "image_url": image_url})
return jsonify({"success": True, "image_url": None})
else:
# Use iTunes fallback - fetch album art
from core.itunes_client import iTunesClient
itunes = iTunesClient()
image_url = itunes._get_artist_image_from_albums(artist_id)
# Use fallback source - fetch album art
fallback = _get_metadata_fallback_client()
image_url = fallback._get_artist_image_from_albums(artist_id)
return jsonify({"success": True, "image_url": image_url})
except Exception as e:
print(f"Error fetching artist image: {e}")
@ -8485,9 +8489,9 @@ def get_artist_discography(artist_id):
# Determine which source to use
spotify_available = spotify_client and spotify_client.is_spotify_authenticated()
# Import iTunes client for fallback
from core.itunes_client import iTunesClient
itunes_client = iTunesClient()
# Import fallback client for non-Spotify lookups
fallback_client = _get_metadata_fallback_client()
fallback_source = _get_metadata_fallback_source()
print(f"🎤 Fetching discography for artist: {artist_id} (name: {artist_name}, spotify: {spotify_available})")
@ -8518,36 +8522,36 @@ def get_artist_discography(artist_id):
except Exception as e:
print(f"Spotify lookup failed: {e}")
# Try iTunes if Spotify didn't work or if it's a numeric ID
# Try fallback source if Spotify didn't work or if it's a numeric ID
if not albums:
try:
if is_numeric_id:
# It's an iTunes ID, use directly
albums = itunes_client.get_artist_albums(artist_id, album_type='album,single', limit=50)
# It's a numeric ID (iTunes/Deezer), use directly
albums = fallback_client.get_artist_albums(artist_id, album_type='album,single', limit=50)
if albums:
active_source = 'itunes'
print(f"📊 Got {len(albums)} albums from iTunes (direct ID)")
active_source = fallback_source
print(f"📊 Got {len(albums)} albums from {fallback_source} (direct ID)")
elif artist_name:
# Search iTunes by name
print(f"🔄 Trying iTunes search by name: '{artist_name}'")
itunes_artists = itunes_client.search_artists(artist_name, limit=5)
if itunes_artists:
# Search fallback by name
print(f"🔄 Trying {fallback_source} search by name: '{artist_name}'")
fallback_artists = fallback_client.search_artists(artist_name, limit=5)
if fallback_artists:
# Find best match
best_match = None
for artist in itunes_artists:
for artist in fallback_artists:
if artist.name.lower() == artist_name.lower():
best_match = artist
break
if not best_match:
best_match = itunes_artists[0]
best_match = fallback_artists[0]
print(f"✅ Found iTunes artist: {best_match.name} (ID: {best_match.id})")
albums = itunes_client.get_artist_albums(best_match.id, album_type='album,single', limit=50)
print(f"✅ Found {fallback_source} artist: {best_match.name} (ID: {best_match.id})")
albums = fallback_client.get_artist_albums(best_match.id, album_type='album,single', limit=50)
if albums:
active_source = 'itunes'
print(f"📊 Got {len(albums)} albums from iTunes (name search)")
active_source = fallback_source
print(f"📊 Got {len(albums)} albums from {fallback_source} (name search)")
except Exception as e:
print(f"iTunes lookup failed: {e}")
print(f"{fallback_source} lookup failed: {e}")
print(f"📊 Total albums returned: {len(albums)} (source: {active_source})")
@ -9612,11 +9616,10 @@ def enhance_artist_quality(artist_id):
except Exception as e:
print(f"⚠️ [Enhance] Search match failed for {title}: {e}")
# iTunes fallback when Spotify unavailable or no match found
# Fallback source when Spotify unavailable or no match found
if not matched_track_data:
try:
from core.itunes_client import iTunesClient
itunes_client = iTunesClient()
fallback_client = _get_metadata_fallback_client()
itunes_best = None
itunes_best_conf = 0.0
@ -9629,7 +9632,7 @@ def enhance_artist_quality(artist_id):
for search_query in itunes_queries[:3]:
try:
itunes_results = itunes_client.search_tracks(search_query, limit=5)
itunes_results = fallback_client.search_tracks(search_query, limit=5)
if not itunes_results:
continue
for it_track in itunes_results:
@ -9674,13 +9677,13 @@ def enhance_artist_quality(artist_id):
'preview_url': itunes_best.preview_url,
'external_urls': itunes_best.external_urls or {},
}
print(f"🍎 [Enhance] iTunes fallback match for {title}: {itunes_best.artists[0]} - {itunes_best.name} (conf: {itunes_best_conf:.3f})")
print(f"🍎 [Enhance] Fallback match for {title}: {itunes_best.artists[0]} - {itunes_best.name} (conf: {itunes_best_conf:.3f})")
except Exception as e:
print(f"⚠️ [Enhance] iTunes fallback failed for {title}: {e}")
print(f"⚠️ [Enhance] Fallback source failed for {title}: {e}")
if not matched_track_data:
failed_count += 1
failed_tracks.append({'track_id': track_id, 'title': title, 'reason': 'No Spotify or iTunes match'})
failed_tracks.append({'track_id': track_id, 'title': title, 'reason': 'No Spotify or fallback match'})
continue
# Add to wishlist with enhance source
@ -24267,10 +24270,9 @@ def search_itunes_tracks():
else:
if hydrabase_worker and dev_mode_enabled:
hydrabase_worker.enqueue(query, 'track')
from core.itunes_client import iTunesClient
itunes_client = iTunesClient()
tracks = itunes_client.search_tracks(query, limit=limit)
source = 'itunes'
fallback_client = _get_metadata_fallback_client()
tracks = fallback_client.search_tracks(query, limit=limit)
source = _get_metadata_fallback_source()
tracks_dict = [{
'id': t.id,
@ -24326,16 +24328,14 @@ def get_itunes_album_tracks(album_id):
except Exception as e:
logger.warning(f"Hydrabase album_tracks failed for '{album_id}', falling back to iTunes: {e}")
from core.itunes_client import iTunesClient
itunes_client = iTunesClient()
album_data = itunes_client.get_album(album_id)
fallback_client = _get_metadata_fallback_client()
album_data = fallback_client.get_album(album_id)
if not album_data:
return jsonify({"error": "Album not found"}), 404
# Get tracks for this album
tracks_data = itunes_client.get_album_tracks(album_id)
tracks_data = fallback_client.get_album_tracks(album_id)
tracks = tracks_data.get('items', []) if tracks_data else []
# Format response to match Spotify structure for frontend compatibility
@ -24348,12 +24348,12 @@ def get_itunes_album_tracks(album_id):
'album_type': album_data.get('album_type', 'album'),
'images': album_data.get('images', []),
'tracks': tracks,
'source': 'itunes'
'source': _get_metadata_fallback_source()
}
return jsonify(album_dict)
except Exception as e:
logger.error(f"Error fetching iTunes album tracks: {e}")
logger.error(f"Error fetching album tracks: {e}")
return jsonify({"error": str(e)}), 500
@ -24423,15 +24423,14 @@ def get_discover_album(source, album_id):
'source': 'spotify'
})
elif source == 'itunes':
from core.itunes_client import iTunesClient
itunes_client = iTunesClient()
elif source in ('itunes', 'deezer'):
fallback_client = _get_metadata_fallback_client()
album_data = itunes_client.get_album(album_id)
album_data = fallback_client.get_album(album_id)
if not album_data:
return jsonify({"error": "Album not found"}), 404
tracks_data = itunes_client.get_album_tracks(album_id)
tracks_data = fallback_client.get_album_tracks(album_id)
tracks = tracks_data.get('items', []) if tracks_data else []
return jsonify({
@ -24443,7 +24442,7 @@ def get_discover_album(source, album_id):
'album_type': album_data.get('album_type', 'album'),
'images': album_data.get('images', []),
'tracks': tracks,
'source': 'itunes'
'source': source
})
else:
@ -25055,17 +25054,16 @@ def _run_playlist_discovery_worker(playlists, automation_id=None):
try:
_ew_state = _pause_enrichment_workers('mirrored playlist discovery')
use_spotify = spotify_client and spotify_client.is_spotify_authenticated()
discovery_source = 'spotify' if use_spotify else 'itunes'
discovery_source = 'spotify' if use_spotify else _get_metadata_fallback_source()
itunes_client_instance = None
if not use_spotify:
try:
from core.itunes_client import iTunesClient
itunes_client_instance = iTunesClient()
itunes_client_instance = _get_metadata_fallback_client()
except Exception:
print("❌ Neither Spotify nor iTunes available for discovery")
print(f"❌ Neither Spotify nor {_get_metadata_fallback_source()} available for discovery")
_update_automation_progress(automation_id, status='error', progress=100,
phase='Error', log_line='Neither Spotify nor iTunes available',
phase='Error', log_line=f'Neither Spotify nor {_get_metadata_fallback_source()} available',
log_type='error')
return
@ -25467,13 +25465,12 @@ def _run_tidal_discovery_worker(playlist_id):
# Determine which provider to use
use_spotify = spotify_client and spotify_client.is_spotify_authenticated()
discovery_source = 'spotify' if use_spotify else 'itunes'
discovery_source = 'spotify' if use_spotify else _get_metadata_fallback_source()
# Initialize iTunes client if needed
# Initialize fallback client if needed
itunes_client_instance = None
if not use_spotify:
from core.itunes_client import iTunesClient
itunes_client_instance = iTunesClient()
itunes_client_instance = _get_metadata_fallback_client()
print(f"🎵 Starting Tidal discovery for: {playlist.name} (using {discovery_source.upper()})")
@ -25567,15 +25564,15 @@ def _run_tidal_discovery_worker(playlist_id):
state['spotify_matches'] = successful_discoveries
elif not use_spotify and track_result and isinstance(track_result, dict):
# iTunes: Function returns a dict with track data (includes 'confidence' key)
# Fallback: Function returns a dict with track data (includes 'confidence' key)
match_confidence = track_result.pop('confidence', 0.80)
match_data = track_result
match_data['source'] = 'itunes'
# Extract image URL from iTunes album images
_itunes_album = match_data.get('album', {})
_itunes_images = _itunes_album.get('images', []) if isinstance(_itunes_album, dict) else []
if _itunes_images and 'image_url' not in match_data:
match_data['image_url'] = _itunes_images[0].get('url', '')
match_data['source'] = discovery_source
# Extract image URL from album images
_fb_album = match_data.get('album', {})
_fb_images = _fb_album.get('images', []) if isinstance(_fb_album, dict) else []
if _fb_images and 'image_url' not in match_data:
match_data['image_url'] = _fb_images[0].get('url', '')
result['spotify_data'] = match_data
result['match_data'] = match_data
result['status'] = 'found'
@ -25639,16 +25636,16 @@ def _run_tidal_discovery_worker(playlist_id):
def _search_spotify_for_tidal_track(tidal_track, use_spotify=True, itunes_client=None):
"""Search Spotify/iTunes for a Tidal track using matching_engine for better accuracy
"""Search Spotify/fallback for a Tidal track using matching_engine for better accuracy
Args:
tidal_track: The Tidal track to search for
use_spotify: If True, use Spotify; if False, use iTunes
itunes_client: iTunes client instance (required when use_spotify=False)
use_spotify: If True, use Spotify; if False, use fallback source
itunes_client: Fallback client instance (required when use_spotify=False)
Returns:
For Spotify: (Track, raw_data, confidence) tuple or None
For iTunes: dict with track data (includes 'confidence' key) or None
For fallback: dict with track data (includes 'confidence' key) or None
"""
if use_spotify:
if not spotify_client or not spotify_client.is_authenticated():
@ -25667,7 +25664,7 @@ def _search_spotify_for_tidal_track(tidal_track, use_spotify=True, itunes_client
artist_name = artists[0] # Use primary artist
source_duration = getattr(tidal_track, 'duration_ms', 0) or 0
source_name = "Spotify" if use_spotify else "iTunes"
source_name = "Spotify" if use_spotify else _get_metadata_fallback_source().capitalize()
print(f"🔍 Tidal track: '{artist_name}' - '{track_name}' (searching {source_name})")
@ -25762,7 +25759,7 @@ def _search_spotify_for_tidal_track(tidal_track, use_spotify=True, itunes_client
result_artists = best_match.artists if hasattr(best_match, 'artists') else []
result_artist = result_artists[0] if result_artists else 'Unknown'
result_name = best_match.name if hasattr(best_match, 'name') else 'Unknown'
print(f"✅ Final Tidal iTunes match: {result_artist} - {result_name} (confidence: {best_confidence:.3f})")
print(f"✅ Final Tidal {source_name} match: {result_artist} - {result_name} (confidence: {best_confidence:.3f})")
album_name = best_match.album if hasattr(best_match, 'album') else 'Unknown Album'
image_url = best_match.image_url if hasattr(best_match, 'image_url') else ''
@ -25779,7 +25776,7 @@ def _search_spotify_for_tidal_track(tidal_track, use_spotify=True, itunes_client
'images': [{'url': image_url, 'height': 300, 'width': 300}] if image_url else []
},
'duration_ms': duration_ms,
'source': 'itunes',
'source': _get_metadata_fallback_source(),
'confidence': best_confidence
}
else:
@ -25980,6 +25977,22 @@ def _get_deezer_client():
_deezer_client_instance = DeezerClient()
return _deezer_client_instance
def _get_metadata_fallback_source():
"""Get the configured metadata fallback source ('itunes' or 'deezer')."""
try:
return config_manager.get('metadata.fallback_source', 'itunes') or 'itunes'
except Exception:
return 'itunes'
def _get_metadata_fallback_client():
"""Get the active metadata fallback client based on settings.
Returns an iTunesClient or DeezerClient instance with identical interfaces."""
source = _get_metadata_fallback_source()
if source == 'deezer':
return _get_deezer_client()
from core.itunes_client import iTunesClient
return iTunesClient()
@app.route('/api/deezer/playlist/<playlist_id>', methods=['GET'])
def get_deezer_playlist(playlist_id):
"""Fetch a Deezer playlist by ID or URL"""
@ -26371,13 +26384,12 @@ def _run_deezer_discovery_worker(playlist_id):
# Determine which provider to use
use_spotify = spotify_client and spotify_client.is_spotify_authenticated()
discovery_source = 'spotify' if use_spotify else 'itunes'
discovery_source = 'spotify' if use_spotify else _get_metadata_fallback_source()
# Initialize iTunes client if needed
# Initialize fallback client if needed
itunes_client_instance = None
if not use_spotify:
from core.itunes_client import iTunesClient
itunes_client_instance = iTunesClient()
itunes_client_instance = _get_metadata_fallback_client()
print(f"🎵 Starting Deezer discovery for: {playlist['name']} (using {discovery_source.upper()})")
@ -26514,15 +26526,15 @@ def _run_deezer_discovery_worker(playlist_id):
state['spotify_matches'] = successful_discoveries
elif not use_spotify and track_result and isinstance(track_result, dict):
# iTunes: Function returns a dict with track data (includes 'confidence' key)
# Fallback: Function returns a dict with track data (includes 'confidence' key)
match_confidence = track_result.pop('confidence', 0.80)
match_data = track_result
match_data['source'] = 'itunes'
# Extract image URL from iTunes album images
_itunes_album = match_data.get('album', {})
_itunes_images = _itunes_album.get('images', []) if isinstance(_itunes_album, dict) else []
if _itunes_images and 'image_url' not in match_data:
match_data['image_url'] = _itunes_images[0].get('url', '')
match_data['source'] = discovery_source
# Extract image URL from album images
_fb_album = match_data.get('album', {})
_fb_images = _fb_album.get('images', []) if isinstance(_fb_album, dict) else []
if _fb_images and 'image_url' not in match_data:
match_data['image_url'] = _fb_images[0].get('url', '')
result['spotify_data'] = match_data
result['match_data'] = match_data
result['status'] = '✅ Found'
@ -27180,13 +27192,12 @@ def _run_spotify_public_discovery_worker(url_hash):
# Determine which provider to use
use_spotify = spotify_client and spotify_client.is_spotify_authenticated()
discovery_source = 'spotify' if use_spotify else 'itunes'
discovery_source = 'spotify' if use_spotify else _get_metadata_fallback_source()
# Initialize iTunes client if needed
# Initialize fallback client if needed
itunes_client_instance = None
if not use_spotify:
from core.itunes_client import iTunesClient
itunes_client_instance = iTunesClient()
itunes_client_instance = _get_metadata_fallback_client()
print(f"🎵 Starting Spotify Public discovery for: {playlist['name']} (using {discovery_source.upper()})")
@ -27334,15 +27345,15 @@ def _run_spotify_public_discovery_worker(url_hash):
state['spotify_matches'] = successful_discoveries
elif not use_spotify and track_result and isinstance(track_result, dict):
# iTunes: Function returns a dict with track data (includes 'confidence' key)
# Fallback: Function returns a dict with track data (includes 'confidence' key)
match_confidence = track_result.pop('confidence', 0.80)
match_data = track_result
match_data['source'] = 'itunes'
# Extract image URL from iTunes album images
_itunes_album = match_data.get('album', {})
_itunes_images = _itunes_album.get('images', []) if isinstance(_itunes_album, dict) else []
if _itunes_images and 'image_url' not in match_data:
match_data['image_url'] = _itunes_images[0].get('url', '')
match_data['source'] = discovery_source
# Extract image URL from album images
_fb_album = match_data.get('album', {})
_fb_images = _fb_album.get('images', []) if isinstance(_fb_album, dict) else []
if _fb_images and 'image_url' not in match_data:
match_data['image_url'] = _fb_images[0].get('url', '')
result['spotify_data'] = match_data
result['match_data'] = match_data
result['status'] = '✅ Found'
@ -27851,11 +27862,10 @@ def _run_youtube_discovery_worker(url_hash):
# Determine which provider to use (Spotify preferred, iTunes fallback)
use_spotify = spotify_client and spotify_client.is_spotify_authenticated()
discovery_source = 'spotify' if use_spotify else 'itunes'
discovery_source = 'spotify' if use_spotify else _get_metadata_fallback_source()
# Get iTunes client for fallback
from core.itunes_client import iTunesClient
itunes_client = iTunesClient()
# Get fallback client
itunes_client = _get_metadata_fallback_client()
print(f"🔍 Starting {discovery_source} discovery for {len(tracks)} YouTube tracks...")
@ -28168,11 +28178,10 @@ def _run_listenbrainz_discovery_worker(state_key):
# Determine which provider to use (Spotify preferred, iTunes fallback)
use_spotify = spotify_client and spotify_client.is_spotify_authenticated()
discovery_source = 'spotify' if use_spotify else 'itunes'
discovery_source = 'spotify' if use_spotify else _get_metadata_fallback_source()
# Get iTunes client for fallback
from core.itunes_client import iTunesClient
itunes_client = iTunesClient()
# Get fallback client
itunes_client = _get_metadata_fallback_client()
print(f"🔍 Starting {discovery_source} discovery for {len(tracks)} ListenBrainz tracks...")
@ -30145,43 +30154,48 @@ def add_to_watchlist():
return jsonify({"success": False, "error": "Missing artist_id or artist_name"}), 400
database = get_database()
success = database.add_artist_to_watchlist(artist_id, artist_name, profile_id=get_current_profile_id())
# Detect ID type and determine source
is_numeric_id = artist_id.isdigit()
fallback_source = _get_metadata_fallback_source()
source = fallback_source if is_numeric_id else 'spotify'
success = database.add_artist_to_watchlist(artist_id, artist_name, profile_id=get_current_profile_id(), source=source)
if success:
# Detect ID type: iTunes IDs are purely numeric, Spotify IDs are alphanumeric
is_itunes_id = artist_id.isdigit()
# Fetch and cache artist image immediately
try:
if is_itunes_id:
# For iTunes artists, fetch image from iTunes API
# We look up 'album' entity because 'artist' entity doesn't always have artwork
# We fallback to the first album's artwork as the artist image
if is_numeric_id:
# For numeric IDs, fetch image from the configured fallback source
try:
itunes_url = f"https://itunes.apple.com/lookup?id={artist_id}&entity=album&limit=5"
print(f"🔍 Fetching iTunes artist image: {itunes_url}")
resp = requests.get(itunes_url, timeout=5)
image_url = None
if resp.status_code == 200:
data = resp.json()
results = data.get('results', [])
# Iterate results to find one with artwork
for res in results:
if 'artworkUrl100' in res:
# Get highest res by replacing dimensions
# iTunes artwork URLs usually end in .../100x100bb.jpg
image_url = res['artworkUrl100'].replace('100x100', '600x600')
break
if fallback_source == 'deezer':
# Deezer artists have direct image URLs
fallback = _get_metadata_fallback_client()
artist_info = fallback.get_artist(artist_id)
image_url = artist_info.get('images', [{}])[0].get('url') if artist_info and artist_info.get('images') else None
else:
# iTunes: look up album entity for artwork
itunes_url = f"https://itunes.apple.com/lookup?id={artist_id}&entity=album&limit=5"
print(f"🔍 Fetching iTunes artist image: {itunes_url}")
resp = requests.get(itunes_url, timeout=5)
image_url = None
if resp.status_code == 200:
resp_data = resp.json()
results = resp_data.get('results', [])
# Iterate results to find one with artwork
for res in results:
if 'artworkUrl100' in res:
image_url = res['artworkUrl100'].replace('100x100', '600x600')
break
if image_url:
database.update_watchlist_artist_image(artist_id, image_url)
print(f"✅ Cached iTunes artist image for {artist_name}")
print(f"✅ Cached {fallback_source} artist image for {artist_name}")
else:
print(f"⚠️ No artwork found for iTunes artist {artist_name}")
except Exception as itunes_error:
print(f"⚠️ Error fetching iTunes artwork: {itunes_error}")
print(f"⚠️ No artwork found for {fallback_source} artist {artist_name}")
except Exception as fb_error:
print(f"⚠️ Error fetching {fallback_source} artwork: {fb_error}")
elif spotify_client and spotify_client.is_authenticated():
# For Spotify artists, fetch from Spotify API
artist_data = spotify_client.get_artist(artist_id)
@ -30285,27 +30299,39 @@ def add_batch_to_watchlist():
if not artist_id or not artist_name:
continue
# Check if already watched
if database.is_artist_in_watchlist(artist_id, profile_id=get_current_profile_id()):
# Check if already watched (by ID or name)
if database.is_artist_in_watchlist(artist_id, profile_id=get_current_profile_id(), artist_name=artist_name):
skipped += 1
continue
success = database.add_artist_to_watchlist(artist_id, artist_name, profile_id=get_current_profile_id())
is_numeric = artist_id.isdigit()
fb_source = _get_metadata_fallback_source()
src = fb_source if is_numeric else 'spotify'
success = database.add_artist_to_watchlist(artist_id, artist_name, profile_id=get_current_profile_id(), source=src)
if success:
added += 1
# Cache artist image
try:
is_itunes_id = artist_id.isdigit()
if is_itunes_id:
itunes_url = f"https://itunes.apple.com/lookup?id={artist_id}&entity=album&limit=5"
resp = requests.get(itunes_url, timeout=5)
if resp.status_code == 200:
results = resp.json().get('results', [])
for res in results:
if 'artworkUrl100' in res:
image_url = res['artworkUrl100'].replace('100x100', '600x600')
is_numeric_id = artist_id.isdigit()
if is_numeric_id:
fb_source = _get_metadata_fallback_source()
if fb_source == 'deezer':
fb_client = _get_metadata_fallback_client()
fb_artist = fb_client.get_artist(artist_id)
if fb_artist and fb_artist.get('images'):
image_url = fb_artist['images'][0].get('url')
if image_url:
database.update_watchlist_artist_image(artist_id, image_url)
break
else:
itunes_url = f"https://itunes.apple.com/lookup?id={artist_id}&entity=album&limit=5"
resp = requests.get(itunes_url, timeout=5)
if resp.status_code == 200:
results = resp.json().get('results', [])
for res in results:
if 'artworkUrl100' in res:
image_url = res['artworkUrl100'].replace('100x100', '600x600')
database.update_watchlist_artist_image(artist_id, image_url)
break
elif spotify_client and spotify_client.is_authenticated():
artist_data = spotify_client.get_artist(artist_id)
if artist_data and 'images' in artist_data and artist_data['images']:
@ -30368,11 +30394,14 @@ def watchlist_all_unwatched_library_artists():
continue
# Check if already watched (shouldn't be since we filtered, but safety check)
if database.is_artist_in_watchlist(artist_id, profile_id=get_current_profile_id()):
if database.is_artist_in_watchlist(artist_id, profile_id=get_current_profile_id(), artist_name=artist_name):
skipped_already += 1
continue
success = database.add_artist_to_watchlist(artist_id, artist_name, profile_id=get_current_profile_id())
is_numeric = artist_id.isdigit()
fb_source = _get_metadata_fallback_source()
src = fb_source if is_numeric else 'spotify'
success = database.add_artist_to_watchlist(artist_id, artist_name, profile_id=get_current_profile_id(), source=src)
if success:
added += 1
# Use library thumb_url if available (no HTTP calls needed)
@ -30472,19 +30501,20 @@ def check_watchlist_status_batch():
def start_watchlist_scan():
"""Start a watchlist scan for new releases"""
try:
# Check if MetadataService can provide a working client (Spotify OR iTunes)
# Check if MetadataService can provide a working client (Spotify OR fallback)
from core.metadata_service import MetadataService
metadata_service = MetadataService()
# Get active provider - will be either spotify or itunes
# Get active provider - will be spotify or the configured fallback
active_provider = metadata_service.get_active_provider()
provider_info = metadata_service.get_provider_info()
# Verify we have at least one working provider
if not provider_info['spotify_authenticated'] and not provider_info['itunes_available']:
fallback_name = provider_info.get('fallback_source', 'iTunes').capitalize()
return jsonify({
"success": False,
"error": "No music provider available. Please authenticate Spotify or ensure iTunes is accessible."
"success": False,
"error": f"No music provider available. Please authenticate Spotify or ensure {fallback_name} is accessible."
}), 400
logger.info(f"Starting watchlist scan with {active_provider} provider")
@ -31239,9 +31269,9 @@ def watchlist_artist_link_provider(artist_id):
return jsonify({"success": False, "error": "No data provided"}), 400
new_provider_id = data.get('provider_id', '').strip()
provider = data.get('provider', '').strip() # 'spotify' or 'itunes'
provider = data.get('provider', '').strip() # 'spotify', 'itunes', or 'deezer'
if not new_provider_id or provider not in ('spotify', 'itunes'):
if not new_provider_id or provider not in ('spotify', 'itunes', 'deezer'):
return jsonify({"success": False, "error": "Missing provider or provider_id"}), 400
conn = sqlite3.connect(str(database.database_path))
@ -31251,8 +31281,8 @@ def watchlist_artist_link_provider(artist_id):
cursor.execute("""
SELECT id, artist_name, spotify_artist_id, itunes_artist_id
FROM watchlist_artists
WHERE spotify_artist_id = ? OR itunes_artist_id = ?
""", (artist_id, artist_id))
WHERE spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ?
""", (artist_id, artist_id, artist_id))
row = cursor.fetchone()
if not row:
@ -31263,7 +31293,8 @@ def watchlist_artist_link_provider(artist_id):
artist_name = row[1]
# Check for duplicate — another watchlist artist already has this provider ID
col = 'spotify_artist_id' if provider == 'spotify' else 'itunes_artist_id'
col_map = {'spotify': 'spotify_artist_id', 'itunes': 'itunes_artist_id', 'deezer': 'deezer_artist_id'}
col = col_map[provider]
cursor.execute(f"SELECT id, artist_name FROM watchlist_artists WHERE {col} = ? AND id != ?",
(new_provider_id, watchlist_row_id))
duplicate = cursor.fetchone()
@ -31271,12 +31302,8 @@ def watchlist_artist_link_provider(artist_id):
conn.close()
return jsonify({"success": False, "error": f"Another watchlist artist ('{duplicate[1]}') already has this {provider} ID"}), 409
if provider == 'spotify':
cursor.execute("UPDATE watchlist_artists SET spotify_artist_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(new_provider_id, watchlist_row_id))
else:
cursor.execute("UPDATE watchlist_artists SET itunes_artist_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(new_provider_id, watchlist_row_id))
cursor.execute(f"UPDATE watchlist_artists SET {col} = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(new_provider_id, watchlist_row_id))
conn.commit()
conn.close()
@ -32057,11 +32084,11 @@ metadata_update_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix=
def _get_active_discovery_source():
"""
Determine which music source is active for discovery.
Returns 'spotify' if Spotify is authenticated, 'itunes' otherwise.
Returns 'spotify' if Spotify is authenticated, otherwise the configured fallback.
"""
if spotify_client and spotify_client.is_spotify_authenticated():
return 'spotify'
return 'itunes'
return _get_metadata_fallback_source()
@app.route('/api/discover/hero', methods=['GET'])
@ -32074,9 +32101,8 @@ def get_discover_hero():
active_source = _get_active_discovery_source()
print(f"🎵 Discover hero using source: {active_source}")
# Import iTunes client for fallback
from core.itunes_client import iTunesClient
itunes_client = iTunesClient()
# Import fallback client for non-Spotify lookups
itunes_client = _get_metadata_fallback_client()
# Get top similar artists (excluding watchlist, cycled by last_featured)
# Fetch more than needed since strict source filtering may drop many
@ -32098,7 +32124,12 @@ def get_discover_hero():
hero_artists = []
for artist in shuffled_watchlist[:10]:
artist_id = artist.itunes_artist_id if active_source == 'itunes' else artist.spotify_artist_id
if active_source == 'spotify':
artist_id = artist.spotify_artist_id
elif active_source == 'deezer':
artist_id = getattr(artist, 'deezer_artist_id', None) or artist.itunes_artist_id
else:
artist_id = artist.itunes_artist_id
if not artist_id:
continue
@ -32127,35 +32158,41 @@ def get_discover_hero():
for artist in similar_artists:
if active_source == 'spotify' and artist.similar_artist_spotify_id:
valid_artists.append(artist)
elif active_source == 'deezer' and getattr(artist, 'similar_artist_deezer_id', None):
valid_artists.append(artist)
elif active_source == 'itunes' and artist.similar_artist_itunes_id:
valid_artists.append(artist)
# FALLBACK: If no valid artists for iTunes, try to resolve iTunes IDs on-the-fly
if active_source == 'itunes' and not valid_artists:
print(f"[iTunes Fallback] No artists with iTunes IDs found, attempting on-the-fly resolution for {len(similar_artists)} artists")
# FALLBACK: If no valid artists for fallback source, try to resolve IDs on-the-fly
if active_source in ('itunes', 'deezer') and not valid_artists:
print(f"[{active_source} Fallback] No artists with {active_source} IDs found, attempting on-the-fly resolution for {len(similar_artists)} artists")
resolved_count = 0
for artist in similar_artists:
if artist.similar_artist_itunes_id:
existing_id = getattr(artist, f'similar_artist_{active_source}_id', None) or (artist.similar_artist_itunes_id if active_source == 'itunes' else None)
if existing_id:
valid_artists.append(artist)
continue
# Try to resolve iTunes ID by name
# Try to resolve ID by name
try:
itunes_results = itunes_client.search_artists(artist.similar_artist_name, limit=1)
if itunes_results and len(itunes_results) > 0:
itunes_id = itunes_results[0].id
search_results = itunes_client.search_artists(artist.similar_artist_name, limit=1)
if search_results and len(search_results) > 0:
resolved_id = search_results[0].id
# Cache the resolved ID for future use
database.update_similar_artist_itunes_id(artist.id, itunes_id)
# Create a modified artist object with the resolved ID
artist.similar_artist_itunes_id = itunes_id
if active_source == 'deezer':
database.update_similar_artist_deezer_id(artist.id, resolved_id)
artist.similar_artist_deezer_id = resolved_id
else:
database.update_similar_artist_itunes_id(artist.id, resolved_id)
artist.similar_artist_itunes_id = resolved_id
valid_artists.append(artist)
resolved_count += 1
print(f" [Resolved] {artist.similar_artist_name} -> iTunes ID: {itunes_id}")
print(f" [Resolved] {artist.similar_artist_name} -> {active_source} ID: {resolved_id}")
except Exception as resolve_err:
print(f" [Failed] Could not resolve iTunes ID for {artist.similar_artist_name}: {resolve_err}")
print(f" [Failed] Could not resolve {active_source} ID for {artist.similar_artist_name}: {resolve_err}")
# Stop after 10 successful resolutions to avoid rate limiting
if len(valid_artists) >= 10:
break
print(f"[iTunes Fallback] Resolved {resolved_count} artists with iTunes IDs")
print(f"[{active_source} Fallback] Resolved {resolved_count} artists with IDs")
print(f"[Discover Hero] Found {len(valid_artists)} valid artists for source: {active_source}")
@ -32168,6 +32205,8 @@ def get_discover_hero():
# Use the ID for the active source, falling back to the other if needed
if active_source == 'spotify':
artist_id = artist.similar_artist_spotify_id or artist.similar_artist_itunes_id
elif active_source == 'deezer':
artist_id = getattr(artist, 'similar_artist_deezer_id', None) or artist.similar_artist_itunes_id or artist.similar_artist_spotify_id
else:
artist_id = artist.similar_artist_itunes_id or artist.similar_artist_spotify_id
@ -32202,18 +32241,21 @@ def get_discover_hero():
artist.id, artist_data.get('image_url'),
artist_data.get('genres'), artist_data.get('popularity')
)
elif active_source == 'itunes' and artist.similar_artist_itunes_id:
itunes_artist = itunes_client.get_artist(artist.similar_artist_itunes_id)
if itunes_artist:
artist_data['artist_name'] = itunes_artist.get('name', artist.similar_artist_name)
artist_data['image_url'] = itunes_artist.get('images', [{}])[0].get('url') if itunes_artist.get('images') else None
artist_data['genres'] = itunes_artist.get('genres', [])
artist_data['popularity'] = itunes_artist.get('popularity', 0)
# Cache it
database.update_similar_artist_metadata(
artist.id, artist_data.get('image_url'),
artist_data.get('genres'), artist_data.get('popularity')
)
elif active_source in ('itunes', 'deezer'):
fb_artist_id = getattr(artist, 'similar_artist_deezer_id', None) if active_source == 'deezer' else None
fb_artist_id = fb_artist_id or artist.similar_artist_itunes_id
if fb_artist_id:
fb_artist_data = itunes_client.get_artist(fb_artist_id)
if fb_artist_data:
artist_data['artist_name'] = fb_artist_data.get('name', artist.similar_artist_name)
artist_data['image_url'] = fb_artist_data.get('images', [{}])[0].get('url') if fb_artist_data.get('images') else None
artist_data['genres'] = fb_artist_data.get('genres', [])
artist_data['popularity'] = fb_artist_data.get('popularity', 0)
# Cache it
database.update_similar_artist_metadata(
artist.id, artist_data.get('image_url'),
artist_data.get('genres'), artist_data.get('popularity')
)
except Exception as img_err:
print(f"Could not fetch artist image: {img_err}")
@ -32247,11 +32289,15 @@ def get_discover_similar_artists():
for artist in similar_artists:
if active_source == 'spotify' and not artist.similar_artist_spotify_id:
continue
if active_source == 'deezer' and not getattr(artist, 'similar_artist_deezer_id', None) and not artist.similar_artist_itunes_id:
continue
if active_source == 'itunes' and not artist.similar_artist_itunes_id:
continue
if active_source == 'spotify':
artist_id = artist.similar_artist_spotify_id
elif active_source == 'deezer':
artist_id = getattr(artist, 'similar_artist_deezer_id', None) or artist.similar_artist_itunes_id
else:
artist_id = artist.similar_artist_itunes_id
@ -32308,7 +32354,12 @@ def enrich_similar_artists():
cached_artists = database.get_top_similar_artists(limit=500, profile_id=get_current_profile_id())
cache_map = {}
for artist in cached_artists:
ext_id = artist.similar_artist_spotify_id if source == 'spotify' else artist.similar_artist_itunes_id
if source == 'spotify':
ext_id = artist.similar_artist_spotify_id
elif source == 'deezer':
ext_id = getattr(artist, 'similar_artist_deezer_id', None) or artist.similar_artist_itunes_id
else:
ext_id = artist.similar_artist_itunes_id
if ext_id and ext_id not in cache_map:
cache_map[ext_id] = artist
@ -32352,23 +32403,23 @@ def enrich_similar_artists():
_detect_and_set_rate_limit(e, 'enrich_similar_artists')
print(f"Error enriching Spotify batch: {e}")
else:
from core.itunes_client import iTunesClient
itunes_client = iTunesClient()
fallback_client = _get_metadata_fallback_client()
fallback_source = _get_metadata_fallback_source()
for aid in uncached_ids[:50]:
try:
itunes_artist = itunes_client.get_artist(aid)
if itunes_artist:
img_url = itunes_artist.get('images', [{}])[0].get('url') if itunes_artist.get('images') else None
genres = itunes_artist.get('genres', [])[:3]
fb_artist = fallback_client.get_artist(aid)
if fb_artist:
img_url = fb_artist.get('images', [{}])[0].get('url') if fb_artist.get('images') else None
genres = fb_artist.get('genres', [])[:3]
enriched[aid] = {
"artist_name": itunes_artist.get('name'),
"artist_name": fb_artist.get('name'),
"image_url": img_url,
"genres": genres,
"popularity": 0
}
# Cache to DB for future use
database.update_similar_artist_metadata_by_external_id(
aid, 'itunes',
aid, fallback_source,
image_url=img_url, genres=genres, popularity=0
)
except Exception:
@ -32533,6 +32584,8 @@ def get_discover_release_radar():
for track in discovery_tracks:
if active_source == 'spotify' and track.spotify_track_id:
tracks_by_id[track.spotify_track_id] = track
elif active_source == 'deezer' and getattr(track, 'deezer_track_id', None):
tracks_by_id[track.deezer_track_id] = track
elif active_source == 'itunes' and track.itunes_track_id:
tracks_by_id[track.itunes_track_id] = track
@ -32550,9 +32603,10 @@ def get_discover_release_radar():
track_data = None
selected_tracks.append({
"track_id": track.spotify_track_id or track.itunes_track_id,
"track_id": track.spotify_track_id or getattr(track, 'deezer_track_id', None) or track.itunes_track_id,
"spotify_track_id": track.spotify_track_id,
"itunes_track_id": track.itunes_track_id,
"deezer_track_id": getattr(track, 'deezer_track_id', None),
"track_name": track.track_name,
"artist_name": track.artist_name,
"album_name": track.album_name,
@ -32597,6 +32651,8 @@ def get_discover_weekly():
for track in discovery_tracks:
if active_source == 'spotify' and track.spotify_track_id:
tracks_by_id[track.spotify_track_id] = track
elif active_source == 'deezer' and getattr(track, 'deezer_track_id', None):
tracks_by_id[track.deezer_track_id] = track
elif active_source == 'itunes' and track.itunes_track_id:
tracks_by_id[track.itunes_track_id] = track
@ -32614,9 +32670,10 @@ def get_discover_weekly():
track_data = None
selected_tracks.append({
"track_id": track.spotify_track_id or track.itunes_track_id,
"track_id": track.spotify_track_id or getattr(track, 'deezer_track_id', None) or track.itunes_track_id,
"spotify_track_id": track.spotify_track_id,
"itunes_track_id": track.itunes_track_id,
"deezer_track_id": getattr(track, 'deezer_track_id', None),
"track_name": track.track_name,
"artist_name": track.artist_name,
"album_name": track.album_name,
@ -33186,14 +33243,13 @@ def search_artists_for_playlist():
logger.warning(f"Spotify artist search failed, falling back to iTunes: {e}")
if not artists:
from core.itunes_client import iTunesClient
itunes = iTunesClient()
artist_objs = itunes.search_artists(query, limit=10)
fallback = _get_metadata_fallback_client()
artist_objs = fallback.search_artists(query, limit=10)
for artist in artist_objs:
# iTunes artist search rarely returns images — grab from album art
# Fallback artist search may not return images — grab from album art
image = artist.image_url
if not image:
image = itunes._get_artist_image_from_albums(artist.id)
image = fallback._get_artist_image_from_albums(artist.id)
artists.append({
'id': artist.id,
'name': artist.name,
@ -35821,13 +35877,12 @@ def _run_beatport_discovery_worker(url_hash):
# Determine which provider to use
use_spotify = spotify_client and spotify_client.is_spotify_authenticated()
discovery_source = 'spotify' if use_spotify else 'itunes'
discovery_source = 'spotify' if use_spotify else _get_metadata_fallback_source()
# Initialize iTunes client if needed
# Initialize fallback client if needed
itunes_client_instance = None
if not use_spotify:
from core.itunes_client import iTunesClient
itunes_client_instance = iTunesClient()
itunes_client_instance = _get_metadata_fallback_client()
print(f"🔍 Starting {discovery_source.upper()} discovery for {len(tracks)} Beatport tracks...")
@ -36051,7 +36106,7 @@ def _run_beatport_discovery_worker(url_hash):
'artists': formatted_artists,
'album': album_data,
'id': track_id,
'source': 'itunes'
'source': discovery_source
}
state['spotify_matches'] += 1

View file

@ -3626,6 +3626,21 @@
</div>
</div>
<!-- Metadata Source Selection -->
<div class="api-service-frame">
<h4 class="service-title" style="color: #e8e8e8;">Metadata Source</h4>
<div class="form-group">
<label>Fallback Source:</label>
<select id="metadata-fallback-source">
<option value="itunes">iTunes / Apple Music</option>
<option value="deezer">Deezer</option>
</select>
</div>
<div class="callback-info">
<div class="callback-help">When Spotify is not connected, this source provides artist, album, and track metadata. Both are free and require no API key.</div>
</div>
</div>
<!-- Test Connection Buttons -->
<div class="api-test-buttons">
<button class="test-button" onclick="testConnection('spotify')">Test
@ -5644,6 +5659,10 @@
<span class="mcache-stat-pill-label">iTunes</span>
<span class="mcache-stat-pill-value" id="mcache-browse-itunes-count">0</span>
</div>
<div class="mcache-stat-pill">
<span class="mcache-stat-pill-label">Deezer</span>
<span class="mcache-stat-pill-value" id="mcache-browse-deezer-count">0</span>
</div>
<div class="mcache-stat-pill">
<span class="mcache-stat-pill-label">Total Hits</span>
<span class="mcache-stat-pill-value" id="mcache-browse-hits">0</span>
@ -5664,6 +5683,7 @@
<option value="">All Sources</option>
<option value="spotify">Spotify</option>
<option value="itunes">iTunes</option>
<option value="deezer">Deezer</option>
</select>
<select class="mcache-sort-filter" id="mcache-sort-filter" onchange="loadMetadataCacheBrowse()">
<option value="last_accessed_at">Recently Accessed</option>
@ -5836,7 +5856,7 @@
<button class="modal-button modal-button--secondary" onclick="closeRateLimitModal()">Dismiss</button>
<button class="modal-button rate-limit-disconnect-btn" onclick="disconnectSpotifyFromRateLimit()">
Disconnect Spotify
<span class="rate-limit-disconnect-sub">Clear ban, pause enrichment &amp; switch to iTunes</span>
<span class="rate-limit-disconnect-sub">Clear ban, pause enrichment &amp; switch to fallback source</span>
</button>
</div>
</div>

View file

@ -4805,6 +4805,9 @@ async function loadSettingsData() {
// Populate iTunes settings
document.getElementById('itunes-country').value = settings.itunes?.country || 'US';
// Populate Metadata source setting
document.getElementById('metadata-fallback-source').value = settings.metadata?.fallback_source || 'itunes';
// Populate Download settings (right column)
document.getElementById('download-path').value = settings.soulseek?.download_path || './downloads';
document.getElementById('transfer-path').value = settings.soulseek?.transfer_path || './Transfer';
@ -5644,6 +5647,9 @@ async function saveSettings(quiet = false) {
itunes: {
country: document.getElementById('itunes-country').value || 'US'
},
metadata: {
fallback_source: document.getElementById('metadata-fallback-source').value || 'itunes'
},
download_source: {
mode: document.getElementById('download-source-mode').value,
hybrid_primary: document.getElementById('hybrid-primary-source').value,
@ -5760,13 +5766,13 @@ async function saveSettings(quiet = false) {
if (result.success && qualityProfileSaved && lookbackSaved) {
showToast(quiet ? 'Settings auto-saved' : 'Settings saved successfully', 'success');
setTimeout(updateServiceStatus, 1000);
_forceServiceStatusRefresh();
} else if (result.success && qualityProfileSaved && !lookbackSaved) {
showToast('Settings saved, but discovery lookback period failed to save', 'warning');
setTimeout(updateServiceStatus, 1000);
_forceServiceStatusRefresh();
} else if (result.success && !qualityProfileSaved) {
showToast('Settings saved, but quality profile failed to save', 'warning');
setTimeout(updateServiceStatus, 1000);
_forceServiceStatusRefresh();
} else {
showToast(`Failed to save settings: ${result.error}`, 'error', 'set-services');
}
@ -6114,7 +6120,8 @@ async function authenticateSpotify() {
}
async function disconnectSpotify() {
if (!await showConfirmDialog({ title: 'Disconnect Spotify', message: 'Disconnect Spotify? The app will switch to Apple Music/iTunes for metadata.' })) {
const fallbackName = currentMusicSourceName !== 'Spotify' ? currentMusicSourceName : 'the configured fallback source';
if (!await showConfirmDialog({ title: 'Disconnect Spotify', message: `Disconnect Spotify? The app will switch to ${fallbackName} for metadata.` })) {
return;
}
try {
@ -6122,7 +6129,7 @@ async function disconnectSpotify() {
const response = await fetch('/api/spotify/disconnect', { method: 'POST' });
const data = await response.json();
if (data.success) {
showToast('Spotify disconnected. Now using Apple Music/iTunes.', 'success');
showToast(`Spotify disconnected. Now using ${fallbackName}.`, 'success');
// Immediately refresh status to update UI
await fetchAndUpdateServiceStatus();
} else {
@ -6222,7 +6229,7 @@ async function disconnectSpotifyFromRateLimit() {
const data = await response.json();
if (data.success) {
_spotifyRateLimitShown = false;
showToast('Spotify disconnected. Now using Apple Music/iTunes.', 'success');
showToast(`Spotify disconnected. Now using ${currentMusicSourceName}.`, 'success');
await fetchAndUpdateServiceStatus();
if (currentPage === 'discover') {
loadDiscoverPage();
@ -15234,10 +15241,10 @@ async function searchDiscoveryFix() {
const identifier = currentDiscoveryFix.identifier;
const state = listenbrainzPlaylistStates[identifier] || youtubePlaylistStates[identifier];
const discoverySource = state?.discovery_source || state?.discoverySource || 'spotify';
const useItunes = discoverySource === 'itunes';
const useFallback = discoverySource !== 'spotify';
const resultsContainer = fixModalOverlay.querySelector('#fix-modal-results');
const sourceLabel = useItunes ? 'iTunes' : 'Spotify';
const sourceLabel = discoverySource === 'spotify' ? 'Spotify' : discoverySource === 'deezer' ? 'Deezer' : 'iTunes';
resultsContainer.innerHTML = `<div class="loading">🔍 Searching ${sourceLabel}...</div>`;
try {
@ -15245,7 +15252,7 @@ async function searchDiscoveryFix() {
const query = `${artistInput} ${trackInput}`.trim();
// Call appropriate search API based on discovery source
const searchEndpoint = useItunes ? '/api/itunes/search_tracks' : '/api/spotify/search_tracks';
const searchEndpoint = useFallback ? '/api/itunes/search_tracks' : '/api/spotify/search_tracks';
const response = await fetch(`${searchEndpoint}?query=${encodeURIComponent(query)}&limit=20`);
const data = await response.json();
@ -18026,9 +18033,9 @@ async function loadMetadataCacheStats() {
const tracksEl = document.getElementById('mcache-stat-tracks');
const hitsEl = document.getElementById('mcache-stat-hits');
if (artistsEl) artistsEl.textContent = (stats.artists?.spotify || 0) + (stats.artists?.itunes || 0);
if (albumsEl) albumsEl.textContent = (stats.albums?.spotify || 0) + (stats.albums?.itunes || 0);
if (tracksEl) tracksEl.textContent = (stats.tracks?.spotify || 0) + (stats.tracks?.itunes || 0);
if (artistsEl) artistsEl.textContent = (stats.artists?.spotify || 0) + (stats.artists?.itunes || 0) + (stats.artists?.deezer || 0);
if (albumsEl) albumsEl.textContent = (stats.albums?.spotify || 0) + (stats.albums?.itunes || 0) + (stats.albums?.deezer || 0);
if (tracksEl) tracksEl.textContent = (stats.tracks?.spotify || 0) + (stats.tracks?.itunes || 0) + (stats.tracks?.deezer || 0);
if (hitsEl) hitsEl.textContent = stats.total_hits || 0;
} catch (e) {
// Silently fail — cache may not be initialized yet
@ -18203,8 +18210,10 @@ async function loadMetadataCacheBrowseStats() {
const spotifyTotal = (stats.artists?.spotify || 0) + (stats.albums?.spotify || 0) + (stats.tracks?.spotify || 0);
const itunesTotal = (stats.artists?.itunes || 0) + (stats.albums?.itunes || 0) + (stats.tracks?.itunes || 0);
const deezerTotal = (stats.artists?.deezer || 0) + (stats.albums?.deezer || 0) + (stats.tracks?.deezer || 0);
el('mcache-browse-spotify-count', spotifyTotal);
el('mcache-browse-itunes-count', itunesTotal);
el('mcache-browse-deezer-count', deezerTotal);
el('mcache-browse-hits', stats.total_hits || 0);
el('mcache-browse-searches', stats.searches || 0);
} catch (e) { /* ignore */ }
@ -33627,6 +33636,18 @@ function escapeHtml(text) {
// --- Service Status and System Stats Functions ---
async function _forceServiceStatusRefresh() {
// Force an immediate status refresh (bypasses WebSocket check) — used after settings save
try {
const response = await fetch('/status');
if (!response.ok) return;
const data = await response.json();
handleServiceStatusUpdate(data);
} catch (error) {
console.warn('Could not force service status refresh:', error);
}
}
async function fetchAndUpdateServiceStatus() {
if (document.hidden) return; // Skip polling when tab is not visible
if (socketConnected) return; // WebSocket is pushing updates — skip HTTP poll
@ -33669,7 +33690,8 @@ function updateServiceStatus(service, statusData) {
? formatRateLimitDuration(statusData.rate_limit?.remaining_seconds || 0)
: formatRateLimitDuration(statusData.post_ban_cooldown);
const phase = statusData.rate_limited ? 'paused' : 'recovering';
statusText.textContent = `Apple Music (Spotify ${phase} \u2014 ${remaining})`;
const fallbackLabel = statusData.source === 'deezer' ? 'Deezer' : 'Apple Music';
statusText.textContent = `${fallbackLabel} (Spotify ${phase} \u2014 ${remaining})`;
statusText.className = 'service-card-status-text rate-limited';
} else if (statusData.connected) {
indicator.className = 'service-card-indicator connected';
@ -33686,7 +33708,7 @@ function updateServiceStatus(service, statusData) {
if (service === 'spotify' && statusData.source) {
const musicSourceTitleElement = document.getElementById('music-source-title');
if (musicSourceTitleElement) {
const sourceName = statusData.source === 'itunes' ? 'Apple Music' : 'Spotify';
const sourceName = statusData.source === 'spotify' ? 'Spotify' : statusData.source === 'deezer' ? 'Deezer' : 'Apple Music';
musicSourceTitleElement.textContent = sourceName;
// Update global variable for use in discovery modals
currentMusicSourceName = sourceName;
@ -33742,7 +33764,7 @@ function updateSidebarServiceStatus(service, statusData) {
if (service === 'spotify' && statusData.source) {
const musicSourceNameElement = document.getElementById('music-source-name');
if (musicSourceNameElement) {
const sourceName = statusData.source === 'itunes' ? 'Apple Music' : 'Spotify';
const sourceName = statusData.source === 'spotify' ? 'Spotify' : statusData.source === 'deezer' ? 'Deezer' : 'Apple Music';
musicSourceNameElement.textContent = sourceName;
}
}
@ -45423,7 +45445,7 @@ async function openRecommendedArtistsModal() {
// Phase 2: Enrich with images/genres progressively in batches of 50
// Skip artists that already have cached metadata from the initial response
const source = data.source || 'spotify';
const idKey = source === 'spotify' ? 'spotify_artist_id' : 'itunes_artist_id';
const idKey = source === 'spotify' ? 'spotify_artist_id' : source === 'deezer' ? 'deezer_artist_id' : 'itunes_artist_id';
const allIds = data.artists
.filter(a => !a.image_url) // Only enrich artists without cached images
.map(a => a[idKey]).filter(Boolean);
@ -49382,8 +49404,8 @@ async function openDownloadModalForRecentAlbum(albumIndex) {
try {
// Determine source and album ID - use source-agnostic endpoint
const source = album.source || (album.album_spotify_id ? 'spotify' : 'itunes');
const albumId = source === 'spotify' ? album.album_spotify_id : album.album_itunes_id;
const source = album.source || (album.album_spotify_id ? 'spotify' : album.album_deezer_id ? 'deezer' : 'itunes');
const albumId = source === 'spotify' ? album.album_spotify_id : source === 'deezer' ? album.album_deezer_id : album.album_itunes_id;
if (!albumId) {
throw new Error(`No ${source} album ID available`);
@ -49431,7 +49453,7 @@ async function openDownloadModalForRecentAlbum(albumIndex) {
// CRITICAL FIX: Pass proper artist/album context for modal display
const artistContext = {
id: source === 'spotify' ? album.artist_spotify_id : album.artist_itunes_id,
id: source === 'spotify' ? album.artist_spotify_id : source === 'deezer' ? album.artist_deezer_id : album.artist_itunes_id,
name: album.artist_name,
source: source
};