Add Deezer as configurable free metadata fallback source alongside iTunes

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

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

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

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

View file

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

View file

@ -4,7 +4,9 @@ import time
import threading import threading
from typing import Dict, List, Optional, Any from typing import Dict, List, Optional, Any
from functools import wraps from functools import wraps
from dataclasses import dataclass
from utils.logging_config import get_logger from utils.logging_config import get_logger
from core.metadata_cache import get_metadata_cache
logger = get_logger("deezer_client") logger = get_logger("deezer_client")
@ -40,8 +42,160 @@ def rate_limited(func):
return wrapper 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: 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" BASE_URL = "https://api.deezer.com"
@ -53,10 +207,441 @@ class DeezerClient:
}) })
logger.info("Deezer client initialized") 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 @rate_limited
def search_artist(self, artist_name: str) -> Optional[Dict[str, Any]]: 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: Args:
artist_name: Name of the artist to search for artist_name: Name of the artist to search for
@ -92,7 +677,7 @@ class DeezerClient:
@rate_limited @rate_limited
def search_album(self, artist_name: str, album_title: str) -> Optional[Dict[str, Any]]: 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: Args:
artist_name: Name of the artist artist_name: Name of the artist
@ -130,7 +715,7 @@ class DeezerClient:
@rate_limited @rate_limited
def search_track(self, artist_name: str, track_title: str) -> Optional[Dict[str, Any]]: 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: Args:
artist_name: Name of the artist artist_name: Name of the artist
@ -166,9 +751,10 @@ class DeezerClient:
return None return None
@rate_limited @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: Args:
album_id: Deezer album ID album_id: Deezer album ID
@ -196,9 +782,10 @@ class DeezerClient:
return None return None
@rate_limited @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: Args:
track_id: Deezer track ID track_id: Deezer track ID

View file

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

View file

@ -465,9 +465,9 @@ class MetadataCache:
cursor = conn.cursor() cursor = conn.cursor()
stats = { stats = {
'artists': {'spotify': 0, 'itunes': 0}, 'artists': {'spotify': 0, 'itunes': 0, 'deezer': 0},
'albums': {'spotify': 0, 'itunes': 0}, 'albums': {'spotify': 0, 'itunes': 0, 'deezer': 0},
'tracks': {'spotify': 0, 'itunes': 0}, 'tracks': {'spotify': 0, 'itunes': 0, 'deezer': 0},
'searches': 0, 'searches': 0,
'total_entries': 0, 'total_entries': 0,
'total_hits': 0, 'total_hits': 0,
@ -509,9 +509,9 @@ class MetadataCache:
except Exception as e: except Exception as e:
logger.error(f"Cache stats error: {e}") logger.error(f"Cache stats error: {e}")
return { return {
'artists': {'spotify': 0, 'itunes': 0}, 'artists': {'spotify': 0, 'itunes': 0, 'deezer': 0},
'albums': {'spotify': 0, 'itunes': 0}, 'albums': {'spotify': 0, 'itunes': 0, 'deezer': 0},
'tracks': {'spotify': 0, 'itunes': 0}, 'tracks': {'spotify': 0, 'itunes': 0, 'deezer': 0},
'searches': 0, 'total_entries': 0, 'total_hits': 0, 'searches': 0, 'total_entries': 0, 'total_hits': 0,
'oldest': None, 'newest': None, 'oldest': None, 'newest': None,
} }
@ -610,6 +610,8 @@ class MetadataCache:
return self._extract_spotify_fields(entity_type, raw_data) return self._extract_spotify_fields(entity_type, raw_data)
elif source == 'itunes': elif source == 'itunes':
return self._extract_itunes_fields(entity_type, raw_data) 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', '')))} return {'name': str(raw_data.get('name', raw_data.get('trackName', '')))}
def _extract_spotify_fields(self, entity_type: str, data: dict) -> dict: def _extract_spotify_fields(self, entity_type: str, data: dict) -> dict:
@ -664,6 +666,60 @@ class MetadataCache:
return fields 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: def _extract_itunes_fields(self, entity_type: str, data: dict) -> dict:
"""Extract fields from iTunes API response.""" """Extract fields from iTunes API response."""
fields = {} fields = {}

View file

@ -1,7 +1,8 @@
""" """
Metadata Service - Hot-swappable Spotify/iTunes provider Metadata Service - Hot-swappable Spotify/iTunes/Deezer provider
Automatically uses Spotify when authenticated, falls back to iTunes when not. Automatically uses Spotify when authenticated, falls back to the configured
fallback source (iTunes or Deezer) when not.
Provides unified interface for all metadata operations. Provides unified interface for all metadata operations.
""" """
@ -15,14 +16,33 @@ logger = get_logger("metadata_service")
MetadataProvider = Literal["spotify", "itunes", "auto"] 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: 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: Usage:
service = MetadataService() service = MetadataService()
tracks = service.search_tracks("Radiohead OK Computer") 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"): def __init__(self, preferred_provider: MetadataProvider = "auto"):
@ -31,22 +51,23 @@ class MetadataService:
Args: Args:
preferred_provider: "spotify", "itunes", or "auto" (default) 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) - "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.preferred_provider = preferred_provider
self.spotify = SpotifyClient() 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() self._log_initialization()
def _log_initialization(self): def _log_initialization(self):
"""Log initialization status""" """Log initialization status"""
spotify_status = "✅ Authenticated" if self.spotify.is_spotify_authenticated() else "❌ Not authenticated" spotify_status = "✅ Authenticated" if self.spotify.is_spotify_authenticated() else "❌ Not authenticated"
itunes_status = "✅ Available" if self.itunes.is_authenticated() else "❌ Not available" fallback_status = "✅ Available" if self.itunes.is_authenticated() else "❌ Not available"
logger.info(f"MetadataService initialized - Spotify: {spotify_status}, iTunes: {itunes_status}") logger.info(f"MetadataService initialized - Spotify: {spotify_status}, {self._fallback_source.capitalize()}: {fallback_status}")
logger.info(f"Preferred provider: {self.preferred_provider}") logger.info(f"Preferred provider: {self.preferred_provider}")
def get_active_provider(self) -> str: def get_active_provider(self) -> str:
@ -54,16 +75,16 @@ class MetadataService:
Get the currently active metadata provider. Get the currently active metadata provider.
Returns: Returns:
"spotify" or "itunes" "spotify" or the configured fallback source name
""" """
if self.preferred_provider == "spotify": if self.preferred_provider == "spotify":
return "spotify" return "spotify"
elif self.preferred_provider == "itunes": elif self.preferred_provider == "itunes":
return "itunes" return self._fallback_source
else: # auto else: # auto
# Use is_spotify_authenticated() to check actual Spotify auth status # Use is_spotify_authenticated() to check actual Spotify auth status
# (is_authenticated() always returns True due to iTunes fallback) # (is_authenticated() always returns True due to fallback)
return "spotify" if self.spotify.is_spotify_authenticated() else "itunes" return "spotify" if self.spotify.is_spotify_authenticated() else self._fallback_source
def _get_client(self): def _get_client(self):
"""Get the appropriate client based on provider selection""" """Get the appropriate client based on provider selection"""
@ -71,7 +92,7 @@ class MetadataService:
if provider == "spotify": if provider == "spotify":
if not self.spotify.is_spotify_authenticated(): 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.itunes
return self.spotify return self.spotify
else: else:
@ -200,6 +221,7 @@ class MetadataService:
"active_provider": self.get_active_provider(), "active_provider": self.get_active_provider(),
"spotify_authenticated": self.spotify.is_spotify_authenticated(), "spotify_authenticated": self.spotify.is_spotify_authenticated(),
"itunes_available": self.itunes.is_authenticated(), "itunes_available": self.itunes.is_authenticated(),
"fallback_source": self._fallback_source,
"preferred_provider": self.preferred_provider, "preferred_provider": self.preferred_provider,
"can_access_user_data": self.spotify.is_spotify_authenticated(), "can_access_user_data": self.spotify.is_spotify_authenticated(),
} }
@ -208,7 +230,13 @@ class MetadataService:
"""Reload configuration for both clients""" """Reload configuration for both clients"""
logger.info("Reloading metadata service configuration") logger.info("Reloading metadata service configuration")
self.spotify.reload_config() 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() self._log_initialization()

View file

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

View file

@ -146,10 +146,10 @@ class RepairWorker:
def itunes_client(self): def itunes_client(self):
if self._itunes_client is None: if self._itunes_client is None:
try: try:
from core.itunes_client import iTunesClient from core.metadata_service import _create_fallback_client
self._itunes_client = iTunesClient() self._itunes_client = _create_fallback_client()
except Exception as e: 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 return self._itunes_client
@property @property

View file

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

View file

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

View file

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

View file

@ -87,6 +87,7 @@ class WatchlistArtist:
updated_at: Optional[datetime] = None updated_at: Optional[datetime] = None
image_url: Optional[str] = None image_url: Optional[str] = None
itunes_artist_id: Optional[str] = None # Cross-provider support itunes_artist_id: Optional[str] = None # Cross-provider support
deezer_artist_id: Optional[str] = None # Cross-provider support
include_albums: bool = True include_albums: bool = True
include_eps: bool = True include_eps: bool = True
include_singles: bool = True include_singles: bool = True
@ -99,7 +100,7 @@ class WatchlistArtist:
@dataclass @dataclass
class SimilarArtist: class SimilarArtist:
"""Similar artist recommendation from Spotify/iTunes""" """Similar artist recommendation from Spotify/iTunes/Deezer"""
id: int id: int
source_artist_id: str # Watchlist artist's database ID source_artist_id: str # Watchlist artist's database ID
similar_artist_spotify_id: Optional[str] # Spotify artist ID (may be None if iTunes-only) 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 image_url: Optional[str] = None # Cached artist image
genres: Optional[List[str]] = None # Cached genres genres: Optional[List[str]] = None # Cached genres
popularity: int = 0 # Cached popularity score popularity: int = 0 # Cached popularity score
similar_artist_deezer_id: Optional[str] = None # Deezer artist ID
@dataclass @dataclass
class DiscoveryTrack: class DiscoveryTrack:
@ -122,7 +124,10 @@ class DiscoveryTrack:
itunes_track_id: Optional[str] # iTunes track ID (None if Spotify source) itunes_track_id: Optional[str] # iTunes track ID (None if Spotify source)
itunes_album_id: Optional[str] # iTunes album ID (None if Spotify source) itunes_album_id: Optional[str] # iTunes album ID (None if Spotify source)
itunes_artist_id: Optional[str] # iTunes artist 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 track_name: str
artist_name: str artist_name: str
album_name: str album_name: str
@ -141,7 +146,8 @@ class RecentRelease:
watchlist_artist_id: int watchlist_artist_id: int
album_spotify_id: Optional[str] # Spotify album ID (None if iTunes source) album_spotify_id: Optional[str] # Spotify album ID (None if iTunes source)
album_itunes_id: Optional[str] # iTunes album ID (None if Spotify 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 album_name: str
release_date: str release_date: str
album_cover_url: Optional[str] album_cover_url: Optional[str]
@ -846,6 +852,13 @@ class MusicDatabase:
cursor.execute("ALTER TABLE discovery_pool ADD COLUMN source TEXT DEFAULT 'spotify'") 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") 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 # Migration: Add iTunes ID to similar_artists for dual-source discovery
cursor.execute("PRAGMA table_info(similar_artists)") cursor.execute("PRAGMA table_info(similar_artists)")
similar_artists_columns = [column[1] for column in cursor.fetchall()] 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") 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") 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 # Migration: Add iTunes columns to recent_releases for dual-source discovery
cursor.execute("PRAGMA table_info(recent_releases)") cursor.execute("PRAGMA table_info(recent_releases)")
recent_releases_columns = [column[1] for column in cursor.fetchall()] 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'") 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") 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 # Migration: Add iTunes columns to discovery_recent_albums for dual-source discovery
cursor.execute("PRAGMA table_info(discovery_recent_albums)") cursor.execute("PRAGMA table_info(discovery_recent_albums)")
discovery_recent_albums_columns = [column[1] for column in cursor.fetchall()] 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'") cursor.execute("ALTER TABLE discovery_recent_albums ADD COLUMN source TEXT DEFAULT 'spotify'")
logger.info("Added iTunes columns to discovery_recent_albums table for dual-source discovery") logger.info("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) # 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 # 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'") 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_itunes_track ON discovery_pool (itunes_track_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_artist ON discovery_pool (spotify_artist_id)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_artist ON discovery_pool (spotify_artist_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_itunes_artist ON discovery_pool (itunes_artist_id)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_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_source ON discovery_pool (source)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_added_date ON discovery_pool (added_date)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_added_date ON discovery_pool (added_date)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_is_new ON discovery_pool (is_new_release)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_is_new ON discovery_pool (is_new_release)")
@ -1099,6 +1129,10 @@ class MusicDatabase:
cursor.execute("ALTER TABLE watchlist_artists ADD COLUMN itunes_artist_id TEXT") 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") 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: except Exception as e:
logger.error(f"Error adding itunes_artist_id column to watchlist_artists: {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 # Don't raise - this is a migration, database can still function
@ -5428,28 +5462,63 @@ class MusicDatabase:
return 0 return 0
# Watchlist operations # 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. """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: try:
with self._get_connection() as conn: with self._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
# Detect ID type: iTunes IDs are purely numeric, Spotify IDs are alphanumeric # Check if artist already exists by name (case-insensitive) for this profile
is_itunes_id = artist_id.isdigit() 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(""" 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) (itunes_artist_id, artist_name, date_added, updated_at, profile_id)
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?) VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?)
""", (artist_id, artist_name, profile_id)) """, (artist_id, artist_name, profile_id))
logger.info(f"Added artist '{artist_name}' to watchlist (iTunes ID: {artist_id}, profile: {profile_id})") logger.info(f"Added artist '{artist_name}' to watchlist (iTunes ID: {artist_id}, profile: {profile_id})")
else: else:
cursor.execute(""" cursor.execute("""
INSERT OR REPLACE INTO watchlist_artists INSERT INTO watchlist_artists
(spotify_artist_id, artist_name, date_added, updated_at, profile_id) (spotify_artist_id, artist_name, date_added, updated_at, profile_id)
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?) VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?)
""", (artist_id, artist_name, profile_id)) """, (artist_id, artist_name, profile_id))
@ -5463,23 +5532,23 @@ class MusicDatabase:
return False return False
def remove_artist_from_watchlist(self, artist_id: str, profile_id: int = 1) -> bool: 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: try:
with self._get_connection() as conn: with self._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
# Get artist name for logging (check both ID columns) # Get artist name for logging (check all ID columns)
cursor.execute(""" cursor.execute("""
SELECT artist_name FROM watchlist_artists SELECT artist_name FROM watchlist_artists
WHERE (spotify_artist_id = ? OR itunes_artist_id = ?) AND profile_id = ? WHERE (spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ?) AND profile_id = ?
""", (artist_id, artist_id, profile_id)) """, (artist_id, artist_id, artist_id, profile_id))
result = cursor.fetchone() result = cursor.fetchone()
artist_name = result['artist_name'] if result else "Unknown" artist_name = result['artist_name'] if result else "Unknown"
cursor.execute(""" cursor.execute("""
DELETE FROM watchlist_artists DELETE FROM watchlist_artists
WHERE (spotify_artist_id = ? OR itunes_artist_id = ?) AND profile_id = ? WHERE (spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ?) AND profile_id = ?
""", (artist_id, artist_id, profile_id)) """, (artist_id, artist_id, artist_id, profile_id))
if cursor.rowcount > 0: if cursor.rowcount > 0:
conn.commit() conn.commit()
@ -5493,18 +5562,25 @@ class MusicDatabase:
logger.error(f"Error removing artist from watchlist (ID: {artist_id}): {e}") logger.error(f"Error removing artist from watchlist (ID: {artist_id}): {e}")
return False return False
def is_artist_in_watchlist(self, artist_id: str, profile_id: int = 1) -> bool: 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 both Spotify and iTunes IDs)""" """Check if an artist is currently in the watchlist (checks Spotify, iTunes, Deezer IDs and name)"""
try: try:
with self._get_connection() as conn: with self._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
# Check both spotify_artist_id and itunes_artist_id columns # Check all ID columns and optionally artist name
cursor.execute(""" if artist_name:
SELECT 1 FROM watchlist_artists cursor.execute("""
WHERE (spotify_artist_id = ? OR itunes_artist_id = ?) AND profile_id = ? SELECT 1 FROM watchlist_artists
LIMIT 1 WHERE (spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ? OR LOWER(artist_name) = LOWER(?)) AND profile_id = ?
""", (artist_id, artist_id, 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() result = cursor.fetchone()
return result is not None return result is not None
@ -5526,7 +5602,7 @@ class MusicDatabase:
# Build SELECT query based on existing columns # Build SELECT query based on existing columns
base_columns = ['id', 'spotify_artist_id', 'artist_name', 'date_added', base_columns = ['id', 'spotify_artist_id', 'artist_name', 'date_added',
'last_scan_timestamp', 'created_at', 'updated_at'] 'last_scan_timestamp', 'created_at', 'updated_at']
optional_columns = ['image_url', '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_live', 'include_remixes', 'include_acoustic', 'include_compilations',
'include_instrumentals'] 'include_instrumentals']
@ -5553,6 +5629,7 @@ class MusicDatabase:
# Safely get optional columns with defaults (sqlite3.Row uses dict-style access) # Safely get optional columns with defaults (sqlite3.Row uses dict-style access)
image_url = row['image_url'] if 'image_url' in existing_columns else None image_url = row['image_url'] if 'image_url' in existing_columns else None
itunes_artist_id = row['itunes_artist_id'] if 'itunes_artist_id' in existing_columns else None 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_albums = bool(row['include_albums']) if 'include_albums' in existing_columns else True
include_eps = bool(row['include_eps']) if 'include_eps' in existing_columns else True include_eps = bool(row['include_eps']) if 'include_eps' in existing_columns else True
include_singles = bool(row['include_singles']) if 'include_singles' in existing_columns else True include_singles = bool(row['include_singles']) if 'include_singles' in existing_columns else True
@ -5572,6 +5649,7 @@ class MusicDatabase:
updated_at=datetime.fromisoformat(row['updated_at']) if row['updated_at'] else None, updated_at=datetime.fromisoformat(row['updated_at']) if row['updated_at'] else None,
image_url=image_url, image_url=image_url,
itunes_artist_id=itunes_artist_id, itunes_artist_id=itunes_artist_id,
deezer_artist_id=deezer_artist_id,
include_albums=include_albums, include_albums=include_albums,
include_eps=include_eps, include_eps=include_eps,
include_singles=include_singles, include_singles=include_singles,
@ -5840,6 +5918,27 @@ class MusicDatabase:
logger.error(f"Error caching watchlist iTunes ID: {e}") logger.error(f"Error caching watchlist iTunes ID: {e}")
return False 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 === # === Discovery Feature Methods ===
def add_or_update_similar_artist(self, source_artist_id: str, similar_artist_name: str, 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, profile_id: int = 1,
image_url: Optional[str] = None, image_url: Optional[str] = None,
genres: Optional[list] = None, genres: Optional[list] = None,
popularity: int = 0) -> bool: popularity: int = 0,
"""Add or update a similar artist recommendation (supports both Spotify and iTunes IDs)""" similar_artist_deezer_id: Optional[str] = None) -> bool:
"""Add or update a similar artist recommendation (supports Spotify, iTunes, and Deezer IDs)"""
try: try:
with self._get_connection() as conn: with self._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
@ -5859,14 +5959,15 @@ class MusicDatabase:
# Use artist name as the unique key (allows storing both IDs for same artist) # Use artist name as the unique key (allows storing both IDs for same artist)
cursor.execute(""" cursor.execute("""
INSERT INTO similar_artists INSERT INTO similar_artists
(source_artist_id, similar_artist_spotify_id, similar_artist_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, similarity_rank, occurrence_count, last_updated, profile_id,
image_url, genres, popularity, metadata_updated_at) 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) ON CONFLICT(profile_id, source_artist_id, similar_artist_name)
DO UPDATE SET DO UPDATE SET
similar_artist_spotify_id = COALESCE(excluded.similar_artist_spotify_id, similar_artist_spotify_id), similar_artist_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_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, similarity_rank = excluded.similarity_rank,
occurrence_count = occurrence_count + 1, occurrence_count = occurrence_count + 1,
last_updated = CURRENT_TIMESTAMP, last_updated = CURRENT_TIMESTAMP,
@ -5874,7 +5975,7 @@ class MusicDatabase:
genres = COALESCE(excluded.genres, genres), genres = COALESCE(excluded.genres, genres),
popularity = CASE WHEN excluded.popularity > 0 THEN excluded.popularity ELSE popularity END, 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 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)) similarity_rank, profile_id, image_url, genres_json, popularity))
conn.commit() conn.commit()
@ -5905,7 +6006,8 @@ class MusicDatabase:
similar_artist_name=row['similar_artist_name'], similar_artist_name=row['similar_artist_name'],
similarity_rank=row['similarity_rank'], similarity_rank=row['similarity_rank'],
occurrence_count=row['occurrence_count'], occurrence_count=row['occurrence_count'],
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] ) for row in rows]
except Exception as e: 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]: 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)""" """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: try:
col = 'similar_artist_deezer_id' if fallback_source == 'deezer' else 'similar_artist_itunes_id'
with self._get_connection() as conn: with self._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(""" cursor.execute(f"""
SELECT * FROM similar_artists SELECT * FROM similar_artists
WHERE source_artist_id = ? AND profile_id = ? 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 ORDER BY occurrence_count DESC
LIMIT 50 LIMIT 50
""", (source_artist_id, profile_id)) """, (source_artist_id, profile_id))
@ -5931,15 +6038,16 @@ class MusicDatabase:
id=row['id'], id=row['id'],
source_artist_id=row['source_artist_id'], source_artist_id=row['source_artist_id'],
similar_artist_spotify_id=row['similar_artist_spotify_id'], similar_artist_spotify_id=row['similar_artist_spotify_id'],
similar_artist_itunes_id=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'], similar_artist_name=row['similar_artist_name'],
similarity_rank=row['similarity_rank'], similarity_rank=row['similarity_rank'],
occurrence_count=row['occurrence_count'], occurrence_count=row['occurrence_count'],
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] ) for row in rows]
except Exception as e: 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 [] return []
def update_similar_artist_itunes_id(self, similar_artist_id: int, itunes_id: str) -> bool: 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}") logger.error(f"Error updating similar artist iTunes ID: {e}")
return False 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, def update_similar_artist_metadata(self, similar_artist_id: int, image_url: str = None,
genres: list = None, popularity: int = None) -> bool: genres: list = None, popularity: int = None) -> bool:
"""Cache artist metadata (image, genres, popularity) to avoid repeated API calls""" """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 genres_json = json.dumps(genres) if genres else None
if source == 'spotify': if source == 'spotify':
where_clause = "similar_artist_spotify_id = ?" where_clause = "similar_artist_spotify_id = ?"
elif source == 'deezer':
where_clause = "similar_artist_deezer_id = ?"
else: else:
where_clause = "similar_artist_itunes_id = ?" where_clause = "similar_artist_itunes_id = ?"
cursor.execute(f""" cursor.execute(f"""
@ -6091,6 +6220,7 @@ class MusicDatabase:
MAX(sa.source_artist_id) as source_artist_id, MAX(sa.source_artist_id) as source_artist_id,
MAX(sa.similar_artist_spotify_id) as similar_artist_spotify_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_itunes_id) as similar_artist_itunes_id,
MAX(sa.similar_artist_deezer_id) as similar_artist_deezer_id,
sa.similar_artist_name, sa.similar_artist_name,
AVG(sa.similarity_rank) as similarity_rank, AVG(sa.similarity_rank) as similarity_rank,
SUM(sa.occurrence_count) as occurrence_count, SUM(sa.occurrence_count) as occurrence_count,
@ -6102,6 +6232,7 @@ class MusicDatabase:
LEFT JOIN watchlist_artists wa ON ( LEFT JOIN watchlist_artists wa ON (
(sa.similar_artist_spotify_id IS NOT NULL AND sa.similar_artist_spotify_id = wa.spotify_artist_id) (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_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) OR LOWER(sa.similar_artist_name) = LOWER(wa.artist_name)
) AND wa.profile_id = ? ) AND wa.profile_id = ?
WHERE wa.id IS NULL AND sa.profile_id = ? WHERE wa.id IS NULL AND sa.profile_id = ?
@ -6127,6 +6258,7 @@ class MusicDatabase:
source_artist_id=row['source_artist_id'], source_artist_id=row['source_artist_id'],
similar_artist_spotify_id=row['similar_artist_spotify_id'], similar_artist_spotify_id=row['similar_artist_spotify_id'],
similar_artist_itunes_id=row['similar_artist_itunes_id'] if 'similar_artist_itunes_id' in row.keys() else None, similar_artist_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'], similar_artist_name=row['similar_artist_name'],
similarity_rank=int(row['similarity_rank']), similarity_rank=int(row['similarity_rank']),
occurrence_count=row['occurrence_count'], occurrence_count=row['occurrence_count'],
@ -6171,6 +6303,9 @@ class MusicDatabase:
elif source == 'itunes' and track_data.get('itunes_track_id'): elif source == 'itunes' and track_data.get('itunes_track_id'):
cursor.execute("SELECT COUNT(*) as count FROM discovery_pool WHERE itunes_track_id = ? AND source = 'itunes' AND profile_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)) (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: else:
# Fallback check by track name and artist # 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 = ?", 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 INSERT INTO discovery_pool
(spotify_track_id, spotify_album_id, spotify_artist_id, (spotify_track_id, spotify_album_id, spotify_artist_id,
itunes_track_id, itunes_album_id, itunes_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, 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) 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_track_id'),
track_data.get('spotify_album_id'), track_data.get('spotify_album_id'),
@ -6197,6 +6333,9 @@ class MusicDatabase:
track_data.get('itunes_track_id'), track_data.get('itunes_track_id'),
track_data.get('itunes_album_id'), track_data.get('itunes_album_id'),
track_data.get('itunes_artist_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, source,
track_data['track_name'], track_data['track_name'],
track_data['artist_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_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_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, 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', source=row['source'] if 'source' in row_keys else 'spotify',
track_name=row['track_name'], track_name=row['track_name'],
artist_name=row['artist_name'], artist_name=row['artist_name'],
@ -6309,14 +6451,17 @@ class MusicDatabase:
cursor.execute(""" cursor.execute("""
INSERT OR REPLACE INTO discovery_recent_albums 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) 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_spotify_id'),
album_data.get('album_itunes_id'), album_data.get('album_itunes_id'),
album_data.get('album_deezer_id'),
album_data.get('artist_spotify_id'), album_data.get('artist_spotify_id'),
album_data.get('artist_itunes_id'), album_data.get('artist_itunes_id'),
album_data.get('artist_deezer_id'),
source, source,
album_data['album_name'], album_data['album_name'],
album_data['artist_name'], album_data['artist_name'],
@ -6539,6 +6684,7 @@ class MusicDatabase:
watchlist_artist_id=row['watchlist_artist_id'], watchlist_artist_id=row['watchlist_artist_id'],
album_spotify_id=row['album_spotify_id'], album_spotify_id=row['album_spotify_id'],
album_itunes_id=row['album_itunes_id'] if 'album_itunes_id' in row.keys() else None, album_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', source=row['source'] if 'source' in row.keys() else 'spotify',
album_name=row['album_name'], album_name=row['album_name'],
release_date=row['release_date'], release_date=row['release_date'],

View file

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

View file

@ -3626,6 +3626,21 @@
</div> </div>
</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 --> <!-- Test Connection Buttons -->
<div class="api-test-buttons"> <div class="api-test-buttons">
<button class="test-button" onclick="testConnection('spotify')">Test <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-label">iTunes</span>
<span class="mcache-stat-pill-value" id="mcache-browse-itunes-count">0</span> <span class="mcache-stat-pill-value" id="mcache-browse-itunes-count">0</span>
</div> </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"> <div class="mcache-stat-pill">
<span class="mcache-stat-pill-label">Total Hits</span> <span class="mcache-stat-pill-label">Total Hits</span>
<span class="mcache-stat-pill-value" id="mcache-browse-hits">0</span> <span class="mcache-stat-pill-value" id="mcache-browse-hits">0</span>
@ -5664,6 +5683,7 @@
<option value="">All Sources</option> <option value="">All Sources</option>
<option value="spotify">Spotify</option> <option value="spotify">Spotify</option>
<option value="itunes">iTunes</option> <option value="itunes">iTunes</option>
<option value="deezer">Deezer</option>
</select> </select>
<select class="mcache-sort-filter" id="mcache-sort-filter" onchange="loadMetadataCacheBrowse()"> <select class="mcache-sort-filter" id="mcache-sort-filter" onchange="loadMetadataCacheBrowse()">
<option value="last_accessed_at">Recently Accessed</option> <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 modal-button--secondary" onclick="closeRateLimitModal()">Dismiss</button>
<button class="modal-button rate-limit-disconnect-btn" onclick="disconnectSpotifyFromRateLimit()"> <button class="modal-button rate-limit-disconnect-btn" onclick="disconnectSpotifyFromRateLimit()">
Disconnect Spotify Disconnect Spotify
<span class="rate-limit-disconnect-sub">Clear ban, pause enrichment &amp; switch to iTunes</span> <span class="rate-limit-disconnect-sub">Clear ban, pause enrichment &amp; switch to fallback source</span>
</button> </button>
</div> </div>
</div> </div>

View file

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