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:
parent
a02d14a23a
commit
46ac46134b
14 changed files with 1620 additions and 605 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}'
|
||||
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
507
web_server.py
507
web_server.py
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 & switch to iTunes</span>
|
||||
<span class="rate-limit-disconnect-sub">Clear ban, pause enrichment & switch to fallback source</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue