From 46ac46134b43db0ead43011c1747ae092ae09d47 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Mon, 16 Mar 2026 13:12:12 -0700 Subject: [PATCH] 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 --- api/search.py | 29 +- core/deezer_client.py | 603 ++++++++++++++++++++++++++++++++- core/deezer_worker.py | 4 +- core/metadata_cache.py | 68 +++- core/metadata_service.py | 74 ++-- core/personalized_playlists.py | 8 +- core/repair_worker.py | 6 +- core/seasonal_discovery.py | 36 +- core/spotify_client.py | 218 ++++++------ core/watchlist_scanner.py | 366 ++++++++++++-------- database/music_database.py | 224 +++++++++--- web_server.py | 507 +++++++++++++++------------ webui/index.html | 22 +- webui/static/script.js | 60 ++-- 14 files changed, 1620 insertions(+), 605 deletions(-) diff --git a/api/search.py b/api/search.py index e773da1c..321f144d 100644 --- a/api/search.py +++ b/api/search.py @@ -48,13 +48,14 @@ def register_routes(bp): tracks = [_serialize_track(t) for t in results] return api_success({"tracks": tracks, "source": "spotify"}) - if source in ("itunes", "auto"): - from core.itunes_client import iTunesClient - itunes = iTunesClient() - results = itunes.search_tracks(query, limit=limit) + if source in ("itunes", "deezer", "auto"): + from core.metadata_service import _create_fallback_client, _get_configured_fallback_source + fallback = _create_fallback_client() + fallback_source = _get_configured_fallback_source() + results = fallback.search_tracks(query, limit=limit) if results: tracks = [_serialize_track(t) for t in results] - return api_success({"tracks": tracks, "source": "itunes"}) + return api_success({"tracks": tracks, "source": fallback_source}) return api_success({"tracks": [], "source": source}) except Exception as e: @@ -85,12 +86,13 @@ def register_routes(bp): "source": "spotify", }) - from core.itunes_client import iTunesClient - itunes = iTunesClient() - results = itunes.search_albums(query, limit=limit) + from core.metadata_service import _create_fallback_client, _get_configured_fallback_source + fallback = _create_fallback_client() + fallback_source = _get_configured_fallback_source() + results = fallback.search_albums(query, limit=limit) return api_success({ "albums": [_serialize_album(a) for a in results] if results else [], - "source": "itunes", + "source": fallback_source, }) except Exception as e: return api_error("SEARCH_ERROR", str(e), 500) @@ -120,12 +122,13 @@ def register_routes(bp): "source": "spotify", }) - from core.itunes_client import iTunesClient - itunes = iTunesClient() - results = itunes.search_artists(query, limit=limit) + from core.metadata_service import _create_fallback_client, _get_configured_fallback_source + fallback = _create_fallback_client() + fallback_source = _get_configured_fallback_source() + results = fallback.search_artists(query, limit=limit) return api_success({ "artists": [_serialize_artist(a) for a in results] if results else [], - "source": "itunes", + "source": fallback_source, }) except Exception as e: return api_error("SEARCH_ERROR", str(e), 500) diff --git a/core/deezer_client.py b/core/deezer_client.py index 0874e0a0..65bf47f2 100644 --- a/core/deezer_client.py +++ b/core/deezer_client.py @@ -4,7 +4,9 @@ import time import threading from typing import Dict, List, Optional, Any from functools import wraps +from dataclasses import dataclass from utils.logging_config import get_logger +from core.metadata_cache import get_metadata_cache logger = get_logger("deezer_client") @@ -40,8 +42,160 @@ def rate_limited(func): return wrapper +# ==================== Dataclasses (match iTunesClient / SpotifyClient format) ==================== + +@dataclass +class Track: + id: str + name: str + artists: List[str] + album: str + duration_ms: int + popularity: int + preview_url: Optional[str] = None + external_urls: Optional[Dict[str, str]] = None + image_url: Optional[str] = None + release_date: Optional[str] = None + track_number: Optional[int] = None + disc_number: Optional[int] = None + + @classmethod + def from_deezer_track(cls, track_data: Dict[str, Any]) -> 'Track': + # Extract album image + album_data = track_data.get('album', {}) + album_image_url = None + if isinstance(album_data, dict): + album_image_url = album_data.get('cover_xl') or album_data.get('cover_big') or album_data.get('cover_medium') + + # Get artist name + artist_data = track_data.get('artist', {}) + artist_name = artist_data.get('name', 'Unknown Artist') if isinstance(artist_data, dict) else 'Unknown Artist' + + # Get album name + album_name = '' + if isinstance(album_data, dict): + album_name = album_data.get('title', '') + elif isinstance(album_data, str): + album_name = album_data + + # Build external URLs + external_urls = {} + if track_data.get('link'): + external_urls['deezer'] = track_data['link'] + + return cls( + id=str(track_data.get('id', '')), + name=track_data.get('title', ''), + artists=[artist_name], + album=album_name, + duration_ms=track_data.get('duration', 0) * 1000, # Deezer returns seconds + popularity=track_data.get('rank', 0), + preview_url=track_data.get('preview'), + external_urls=external_urls if external_urls else None, + image_url=album_image_url, + release_date=track_data.get('release_date') or (album_data.get('release_date') if isinstance(album_data, dict) else None), + track_number=track_data.get('track_position'), + disc_number=track_data.get('disk_number', 1), + ) + + +@dataclass +class Artist: + id: str + name: str + popularity: int + genres: List[str] + followers: int + image_url: Optional[str] = None + external_urls: Optional[Dict[str, str]] = None + + @classmethod + def from_deezer_artist(cls, artist_data: Dict[str, Any]) -> 'Artist': + image_url = artist_data.get('picture_xl') or artist_data.get('picture_big') or artist_data.get('picture_medium') + + external_urls = {} + if artist_data.get('link'): + external_urls['deezer'] = artist_data['link'] + + return cls( + id=str(artist_data.get('id', '')), + name=artist_data.get('name', ''), + popularity=0, + genres=[], + followers=artist_data.get('nb_fan', 0), + image_url=image_url, + external_urls=external_urls if external_urls else None + ) + + +@dataclass +class Album: + id: str + name: str + artists: List[str] + release_date: str + total_tracks: int + album_type: str + image_url: Optional[str] = None + external_urls: Optional[Dict[str, str]] = None + + @classmethod + def from_deezer_album(cls, album_data: Dict[str, Any]) -> 'Album': + image_url = album_data.get('cover_xl') or album_data.get('cover_big') or album_data.get('cover_medium') + + external_urls = {} + if album_data.get('link'): + external_urls['deezer'] = album_data['link'] + + artist_data = album_data.get('artist', {}) + artist_name = artist_data.get('name', 'Unknown Artist') if isinstance(artist_data, dict) else 'Unknown Artist' + + # Map Deezer record_type + record_type = album_data.get('record_type', 'album') + if record_type == 'single': + album_type = 'single' + elif record_type == 'ep': + album_type = 'ep' + elif record_type == 'compile': + album_type = 'compilation' + else: + album_type = 'album' + + return cls( + id=str(album_data.get('id', '')), + name=album_data.get('title', ''), + artists=[artist_name], + release_date=album_data.get('release_date', ''), + total_tracks=album_data.get('nb_tracks', 0), + album_type=album_type, + image_url=image_url, + external_urls=external_urls if external_urls else None + ) + + +@dataclass +class Playlist: + id: str + name: str + description: Optional[str] + owner: str + public: bool + collaborative: bool + tracks: List[Track] + total_tracks: int + + class DeezerClient: - """Client for interacting with the Deezer API""" + """ + Deezer API client for music metadata and playlist access. + + Provides metadata parity with iTunesClient for use as a fallback source. + Also provides enrichment methods (search_artist, search_album, search_track) + and playlist methods used by deezer_worker.py. + + Free, no authentication required. + Rate limit: ~50 calls/5s. + """ BASE_URL = "https://api.deezer.com" @@ -53,10 +207,441 @@ class DeezerClient: }) logger.info("Deezer client initialized") + def is_authenticated(self) -> bool: + """Deezer public API requires no authentication — always available""" + return True + + def reload_config(self): + """Reload configuration (no-op for Deezer since no auth required)""" + pass + + def _api_get(self, endpoint: str, params: dict = None, timeout: int = 15) -> Optional[Dict[str, Any]]: + """Generic GET request to Deezer API with error handling""" + try: + url = f"{self.BASE_URL}/{endpoint.lstrip('/')}" + response = self.session.get(url, params=params, timeout=timeout) + + if response.status_code != 200: + logger.error(f"Deezer API returned status {response.status_code} for {endpoint}") + return None + + data = response.json() + + if 'error' in data: + error = data['error'] + error_type = error.get('type', 'Unknown') + error_msg = error.get('message', 'Unknown error') + if error_type == 'DataException': + logger.debug(f"Deezer data not found: {endpoint}") + else: + logger.error(f"Deezer API error ({error_type}): {error_msg}") + return None + + return data + + except Exception as e: + logger.error(f"Error in Deezer API request ({endpoint}): {e}") + return None + + # ==================== Metadata Source Methods (iTunesClient parity) ==================== + # These methods follow the same interface as iTunesClient so DeezerClient + # can serve as a drop-in fallback metadata source in SpotifyClient. + + @rate_limited + def search_tracks(self, query: str, limit: int = 20) -> List[Track]: + """Search for tracks — returns Track dataclass list (metadata source interface)""" + cache = get_metadata_cache() + cached_results = cache.get_search_results('deezer', 'track', query, limit) + if cached_results is not None: + tracks = [] + for raw in cached_results: + try: + tracks.append(Track.from_deezer_track(raw)) + except Exception: + pass + if tracks: + return tracks + + data = self._api_get('search/track', {'q': query, 'limit': min(limit, 100)}) + if not data or 'data' not in data: + return [] + + tracks = [] + raw_items = [] + for track_data in data['data']: + track = Track.from_deezer_track(track_data) + tracks.append(track) + raw_items.append(track_data) + + entries = [(str(td.get('id', '')), td) for td in raw_items if td.get('id')] + if entries: + cache.store_entities_bulk('deezer', 'track', entries) + cache.store_search_results('deezer', 'track', query, limit, + [str(td.get('id', '')) for td in raw_items if td.get('id')]) + + return tracks + + @rate_limited + def search_artists(self, query: str, limit: int = 20) -> List[Artist]: + """Search for artists — returns Artist dataclass list (metadata source interface)""" + cache = get_metadata_cache() + cached_results = cache.get_search_results('deezer', 'artist', query, limit) + if cached_results is not None: + artists = [] + for raw in cached_results: + try: + artists.append(Artist.from_deezer_artist(raw)) + except Exception: + pass + if artists: + return artists + + data = self._api_get('search/artist', {'q': query, 'limit': min(limit, 100)}) + if not data or 'data' not in data: + return [] + + artists = [] + raw_items = [] + for artist_data in data['data']: + artist = Artist.from_deezer_artist(artist_data) + artists.append(artist) + raw_items.append(artist_data) + + entries = [(str(ad.get('id', '')), ad) for ad in raw_items if ad.get('id')] + if entries: + cache.store_entities_bulk('deezer', 'artist', entries) + cache.store_search_results('deezer', 'artist', query, limit, + [str(ad.get('id', '')) for ad in raw_items if ad.get('id')]) + + return artists + + @rate_limited + def search_albums(self, query: str, limit: int = 20) -> List[Album]: + """Search for albums — returns Album dataclass list (metadata source interface)""" + cache = get_metadata_cache() + cached_results = cache.get_search_results('deezer', 'album', query, limit) + if cached_results is not None: + albums = [] + for raw in cached_results: + try: + albums.append(Album.from_deezer_album(raw)) + except Exception: + pass + if albums: + return albums + + data = self._api_get('search/album', {'q': query, 'limit': min(limit, 100)}) + if not data or 'data' not in data: + return [] + + albums = [] + raw_items = [] + for album_data in data['data']: + album = Album.from_deezer_album(album_data) + albums.append(album) + raw_items.append(album_data) + + entries = [(str(ad.get('id', '')), ad) for ad in raw_items if ad.get('id')] + if entries: + cache.store_entities_bulk('deezer', 'album', entries, skip_if_exists=True) + cache.store_search_results('deezer', 'album', query, limit, + [str(ad.get('id', '')) for ad in raw_items if ad.get('id')]) + + return albums[:limit] + + def get_track_details(self, track_id: str) -> Optional[Dict[str, Any]]: + """Get detailed track info — returns Spotify-compatible dict (metadata source interface)""" + cache = get_metadata_cache() + cached = cache.get_entity('deezer', 'track', str(track_id)) + if cached and cached.get('title'): + return self._build_enhanced_track(cached) + + data = self._api_get(f'track/{track_id}') + if not data: + return None + + cache.store_entity('deezer', 'track', str(track_id), data) + return self._build_enhanced_track(data) + + def _build_enhanced_track(self, track_data: Dict[str, Any]) -> Dict[str, Any]: + """Build Spotify-compatible enhanced track dict from raw Deezer data""" + artist_data = track_data.get('artist', {}) + album_data = track_data.get('album', {}) + + artist_name = artist_data.get('name', 'Unknown Artist') if isinstance(artist_data, dict) else 'Unknown Artist' + album_name = album_data.get('title', '') if isinstance(album_data, dict) else str(album_data) if album_data else '' + album_id = str(album_data.get('id', '')) if isinstance(album_data, dict) else '' + + return { + 'id': str(track_data.get('id', '')), + 'name': track_data.get('title', ''), + 'track_number': track_data.get('track_position', 0), + 'disc_number': track_data.get('disk_number', 1), + 'duration_ms': track_data.get('duration', 0) * 1000, + 'explicit': track_data.get('explicit_lyrics', False), + 'artists': [artist_name], + 'primary_artist': artist_name, + 'album': { + 'id': album_id, + 'name': album_name, + 'total_tracks': album_data.get('nb_tracks', 0) if isinstance(album_data, dict) else 0, + 'release_date': track_data.get('release_date', '') or (album_data.get('release_date', '') if isinstance(album_data, dict) else ''), + 'album_type': 'album', + 'artists': [artist_name] + }, + 'is_album_track': (album_data.get('nb_tracks', 0) if isinstance(album_data, dict) else 0) > 1, + 'raw_data': track_data + } + + def get_track_features(self, track_id: str) -> Optional[Dict[str, Any]]: + """Deezer does not provide audio features like Spotify""" + return None + + def get_album_metadata(self, album_id: str, include_tracks: bool = True) -> Optional[Dict[str, Any]]: + """Get album info — returns Spotify-compatible dict (metadata source interface). + + Matches iTunesClient.get_album() interface. The enrichment method below + is get_album_raw() (used by deezer_worker.py).""" + cache = get_metadata_cache() + cached = cache.get_entity('deezer', 'album', str(album_id)) + if cached and cached.get('title'): + return self._build_album_result(cached, album_id, include_tracks) + + data = self._api_get(f'album/{album_id}') + if not data: + return None + + cache.store_entity('deezer', 'album', str(album_id), data) + return self._build_album_result(data, album_id, include_tracks) + + def _build_album_result(self, album_data: Dict[str, Any], album_id: str, include_tracks: bool = True) -> Dict[str, Any]: + """Build Spotify-compatible album result from Deezer data""" + images = [] + for size_key, height in [('cover_xl', 1000), ('cover_big', 500), ('cover_medium', 250), ('cover_small', 56)]: + if album_data.get(size_key): + images.append({'url': album_data[size_key], 'height': height, 'width': height}) + + artist_data = album_data.get('artist', {}) + artist_name = artist_data.get('name', 'Unknown Artist') if isinstance(artist_data, dict) else 'Unknown Artist' + artist_id = str(artist_data.get('id', '')) if isinstance(artist_data, dict) else '' + + record_type = album_data.get('record_type', 'album') + if record_type == 'single': + album_type = 'single' + elif record_type == 'ep': + album_type = 'ep' + elif record_type == 'compile': + album_type = 'compilation' + else: + album_type = 'album' + + album_result = { + 'id': str(album_data.get('id', album_id)), + 'name': album_data.get('title', ''), + 'images': images, + 'artists': [{'name': artist_name, 'id': artist_id}], + 'release_date': album_data.get('release_date', ''), + 'total_tracks': album_data.get('nb_tracks', 0), + 'album_type': album_type, + 'external_urls': {'deezer': album_data.get('link', '')}, + 'uri': f"deezer:album:{album_data.get('id', '')}", + '_source': 'deezer', + '_raw_data': album_data + } + + if include_tracks: + tracks_data = self.get_album_tracks(album_id) + if tracks_data and 'items' in tracks_data: + album_result['tracks'] = tracks_data + else: + album_result['tracks'] = {'items': [], 'total': 0} + + return album_result + + def get_album_tracks(self, album_id: str) -> Optional[Dict[str, Any]]: + """Get album tracks — returns Spotify-compatible format (metadata source interface)""" + cache = get_metadata_cache() + cached = cache.get_entity('deezer', 'album', f"{album_id}_tracks") + if cached: + return cached + + data = self._api_get(f'album/{album_id}/tracks', {'limit': 500}) + if not data or 'data' not in data: + album_data = self._api_get(f'album/{album_id}') + if album_data and 'tracks' in album_data and 'data' in album_data['tracks']: + data = album_data['tracks'] + else: + return None + + # Get album-level info for images and name + album_info = self._api_get(f'album/{album_id}') + album_images = [] + album_name = '' + if album_info: + album_name = album_info.get('title', '') + for size_key, height in [('cover_xl', 1000), ('cover_big', 500), ('cover_medium', 250)]: + if album_info.get(size_key): + album_images.append({'url': album_info[size_key], 'height': height, 'width': height}) + + tracks = [] + for item in data['data']: + artist_data = item.get('artist', {}) + artist_name = artist_data.get('name', 'Unknown Artist') if isinstance(artist_data, dict) else 'Unknown Artist' + + normalized_track = { + 'id': str(item.get('id', '')), + 'name': item.get('title', ''), + 'artists': [{'name': artist_name}], + 'album': { + 'id': str(album_id), + 'name': album_name, + 'images': album_images, + 'release_date': album_info.get('release_date', '') if album_info else '' + }, + 'duration_ms': item.get('duration', 0) * 1000, + 'track_number': item.get('track_position', 0), + 'disc_number': item.get('disk_number', 1), + 'explicit': item.get('explicit_lyrics', False), + 'preview_url': item.get('preview'), + 'uri': f"deezer:track:{item.get('id', '')}", + 'external_urls': {'deezer': item.get('link', '')}, + '_source': 'deezer' + } + tracks.append(normalized_track) + + tracks.sort(key=lambda t: (t.get('disc_number', 1), t.get('track_number', 0))) + + logger.info(f"Retrieved {len(tracks)} tracks for album {album_id}") + + result = { + 'items': tracks, + 'total': len(tracks), + 'limit': len(tracks), + 'next': None + } + + cache.store_entity('deezer', 'album', f"{album_id}_tracks", result) + + # Cache individual tracks + for item in data['data']: + if item.get('id'): + cache.store_entity('deezer', 'track', str(item['id']), item) + + return result + + def get_artist_info(self, artist_id: str) -> Optional[Dict[str, Any]]: + """Get full artist details — returns Spotify-compatible dict (metadata source interface). + + Matches iTunesClient.get_artist() interface.""" + cache = get_metadata_cache() + cached = cache.get_entity('deezer', 'artist', str(artist_id)) + if cached and cached.get('name'): + return self._build_artist_result(cached) + + data = self._api_get(f'artist/{artist_id}') + if not data: + return None + + cache.store_entity('deezer', 'artist', str(artist_id), data) + return self._build_artist_result(data) + + def _build_artist_result(self, artist_data: Dict[str, Any]) -> Dict[str, Any]: + """Build Spotify-compatible artist result from Deezer data""" + images = [] + for size_key, height in [('picture_xl', 1000), ('picture_big', 500), ('picture_medium', 250), ('picture_small', 56)]: + if artist_data.get(size_key): + images.append({'url': artist_data[size_key], 'height': height, 'width': height}) + + return { + 'id': str(artist_data.get('id', '')), + 'name': artist_data.get('name', ''), + 'images': images, + 'genres': [], + 'popularity': 0, + 'followers': {'total': artist_data.get('nb_fan', 0)}, + 'external_urls': {'deezer': artist_data.get('link', '')}, + 'uri': f"deezer:artist:{artist_data.get('id', '')}", + '_source': 'deezer', + '_raw_data': artist_data + } + + def get_artist_albums_list(self, artist_id: str, album_type: str = 'album,single', limit: int = 50) -> List[Album]: + """Get albums by artist ID — returns Album dataclass list (metadata source interface). + + Matches iTunesClient.get_artist_albums() interface.""" + data = self._api_get(f'artist/{artist_id}/albums', {'limit': min(limit, 100)}) + if not data or 'data' not in data: + return [] + + albums = [] + requested_types = [t.strip() for t in album_type.split(',')] + + for album_data in data['data']: + album = Album.from_deezer_album(album_data) + + if album_type != 'album,single': + if album.album_type not in requested_types: + if not (album.album_type == 'ep' and 'single' in requested_types): + continue + + albums.append(album) + + cache = get_metadata_cache() + entries = [(str(ad.get('id', '')), ad) for ad in data['data'] if ad.get('id')] + if entries: + cache.store_entities_bulk('deezer', 'album', entries, skip_if_exists=True) + + logger.info(f"Retrieved {len(albums)} albums for artist {artist_id}") + return albums[:limit] + + # ==================== Interface Aliases (match iTunesClient method names) ==================== + # These allow SpotifyClient to call self._fallback.get_album() etc. without + # conditional dispatch — same method names as iTunesClient. + get_album = get_album_metadata + get_artist = get_artist_info + get_artist_albums = get_artist_albums_list + + def _get_artist_image_from_albums(self, artist_id: str) -> Optional[str]: + """Compatibility with iTunesClient — Deezer artists have direct image URLs.""" + artist_data = self._api_get(f'artist/{artist_id}') + if artist_data: + return artist_data.get('picture_xl') or artist_data.get('picture_big') or artist_data.get('picture_medium') + return None + + # ==================== Stub Methods (match iTunesClient interface) ==================== + + def get_user_playlists(self) -> List[Playlist]: + """Not supported — Deezer playlists require auth""" + return [] + + def get_user_playlists_metadata_only(self) -> List[Playlist]: + """Not supported""" + return [] + + def get_saved_tracks_count(self) -> int: + """Not supported""" + return 0 + + def get_saved_tracks(self) -> List[Track]: + """Not supported""" + return [] + + def get_playlist_by_id(self, playlist_id: str) -> Optional[Playlist]: + """Not supported""" + return None + + def get_user_info(self) -> Optional[Dict[str, Any]]: + """Not supported — requires auth""" + return None + + # ==================== Existing Enrichment Methods ==================== + # These methods are used by deezer_worker.py and web_server.py enrichment endpoints. + # They have different signatures from the metadata-source methods above. + @rate_limited def search_artist(self, artist_name: str) -> Optional[Dict[str, Any]]: """ - Search for an artist by name. + Search for an artist by name (enrichment interface). Args: artist_name: Name of the artist to search for @@ -92,7 +677,7 @@ class DeezerClient: @rate_limited def search_album(self, artist_name: str, album_title: str) -> Optional[Dict[str, Any]]: """ - Search for an album by artist name and album title. + Search for an album by artist name and album title (enrichment interface). Args: artist_name: Name of the artist @@ -130,7 +715,7 @@ class DeezerClient: @rate_limited def search_track(self, artist_name: str, track_title: str) -> Optional[Dict[str, Any]]: """ - Search for a track by artist name and track title. + Search for a track by artist name and track title (enrichment interface). Args: artist_name: Name of the artist @@ -166,9 +751,10 @@ class DeezerClient: return None @rate_limited - def get_album(self, album_id: int) -> Optional[Dict[str, Any]]: + def get_album_raw(self, album_id: int) -> Optional[Dict[str, Any]]: """ - Get full album details by ID. + Get full album details by ID — raw Deezer format (enrichment interface). + Used by deezer_worker.py for label/genre/explicit enrichment. Args: album_id: Deezer album ID @@ -196,9 +782,10 @@ class DeezerClient: return None @rate_limited - def get_track(self, track_id: int) -> Optional[Dict[str, Any]]: + def get_track_raw(self, track_id: int) -> Optional[Dict[str, Any]]: """ - Get full track details by ID (includes BPM). + Get full track details by ID — raw Deezer format (enrichment interface, includes BPM). + Used by deezer_worker.py for BPM enrichment. Args: track_id: Deezer track ID diff --git a/core/deezer_worker.py b/core/deezer_worker.py index 4b28fbc0..d0b9c4fd 100644 --- a/core/deezer_worker.py +++ b/core/deezer_worker.py @@ -363,7 +363,7 @@ class DeezerWorker: full_album = None if deezer_album_id: try: - full_album = self.client.get_album(deezer_album_id) + full_album = self.client.get_album_raw(deezer_album_id) except Exception as e: logger.warning(f"Failed to fetch full album details for '{album_name}' (Deezer ID: {deezer_album_id}): {e}") @@ -403,7 +403,7 @@ class DeezerWorker: full_track = None if deezer_track_id: try: - full_track = self.client.get_track(deezer_track_id) + full_track = self.client.get_track_raw(deezer_track_id) except Exception as e: logger.warning(f"Failed to fetch full track details for '{track_name}' (Deezer ID: {deezer_track_id}): {e}") diff --git a/core/metadata_cache.py b/core/metadata_cache.py index 3f1d9eec..d3ef8872 100644 --- a/core/metadata_cache.py +++ b/core/metadata_cache.py @@ -465,9 +465,9 @@ class MetadataCache: cursor = conn.cursor() stats = { - 'artists': {'spotify': 0, 'itunes': 0}, - 'albums': {'spotify': 0, 'itunes': 0}, - 'tracks': {'spotify': 0, 'itunes': 0}, + 'artists': {'spotify': 0, 'itunes': 0, 'deezer': 0}, + 'albums': {'spotify': 0, 'itunes': 0, 'deezer': 0}, + 'tracks': {'spotify': 0, 'itunes': 0, 'deezer': 0}, 'searches': 0, 'total_entries': 0, 'total_hits': 0, @@ -509,9 +509,9 @@ class MetadataCache: except Exception as e: logger.error(f"Cache stats error: {e}") return { - 'artists': {'spotify': 0, 'itunes': 0}, - 'albums': {'spotify': 0, 'itunes': 0}, - 'tracks': {'spotify': 0, 'itunes': 0}, + 'artists': {'spotify': 0, 'itunes': 0, 'deezer': 0}, + 'albums': {'spotify': 0, 'itunes': 0, 'deezer': 0}, + 'tracks': {'spotify': 0, 'itunes': 0, 'deezer': 0}, 'searches': 0, 'total_entries': 0, 'total_hits': 0, 'oldest': None, 'newest': None, } @@ -610,6 +610,8 @@ class MetadataCache: return self._extract_spotify_fields(entity_type, raw_data) elif source == 'itunes': return self._extract_itunes_fields(entity_type, raw_data) + elif source == 'deezer': + return self._extract_deezer_fields(entity_type, raw_data) return {'name': str(raw_data.get('name', raw_data.get('trackName', '')))} def _extract_spotify_fields(self, entity_type: str, data: dict) -> dict: @@ -664,6 +666,60 @@ class MetadataCache: return fields + def _extract_deezer_fields(self, entity_type: str, data: dict) -> dict: + """Extract fields from Deezer API response.""" + fields = {} + + if entity_type == 'artist': + fields['name'] = data.get('name', '') + fields['genres'] = '[]' + fields['popularity'] = 0 + fields['followers'] = data.get('nb_fan', 0) + fields['image_url'] = data.get('picture_xl') or data.get('picture_big') or data.get('picture_medium') + urls = {} + if data.get('link'): + urls['deezer'] = data['link'] + fields['external_urls'] = json.dumps(urls) + + elif entity_type == 'album': + fields['name'] = data.get('title', '') + artist = data.get('artist', {}) + fields['artist_name'] = artist.get('name', '') if isinstance(artist, dict) else '' + fields['artist_id'] = str(artist.get('id', '')) if isinstance(artist, dict) else '' + fields['release_date'] = data.get('release_date', '') + fields['total_tracks'] = data.get('nb_tracks', 0) + record_type = data.get('record_type', 'album') + fields['album_type'] = record_type if record_type in ('single', 'ep', 'album') else 'album' + fields['label'] = data.get('label', '') + fields['image_url'] = data.get('cover_xl') or data.get('cover_big') or data.get('cover_medium') + urls = {} + if data.get('link'): + urls['deezer'] = data['link'] + fields['external_urls'] = json.dumps(urls) + + elif entity_type == 'track': + fields['name'] = data.get('title', '') + artist = data.get('artist', {}) + fields['artist_name'] = artist.get('name', '') if isinstance(artist, dict) else '' + fields['artist_id'] = str(artist.get('id', '')) if isinstance(artist, dict) else '' + album = data.get('album', {}) + fields['album_name'] = album.get('title', '') if isinstance(album, dict) else '' + fields['album_id'] = str(album.get('id', '')) if isinstance(album, dict) else '' + fields['image_url'] = (album.get('cover_xl') or album.get('cover_big') or album.get('cover_medium')) if isinstance(album, dict) else None + fields['duration_ms'] = data.get('duration', 0) * 1000 + fields['track_number'] = data.get('track_position') + fields['disc_number'] = data.get('disk_number', 1) + fields['explicit'] = 1 if data.get('explicit_lyrics') else 0 + fields['popularity'] = data.get('rank', 0) + fields['isrc'] = data.get('isrc') + fields['preview_url'] = data.get('preview') + urls = {} + if data.get('link'): + urls['deezer'] = data['link'] + fields['external_urls'] = json.dumps(urls) + + return fields + def _extract_itunes_fields(self, entity_type: str, data: dict) -> dict: """Extract fields from iTunes API response.""" fields = {} diff --git a/core/metadata_service.py b/core/metadata_service.py index e1b6fe70..038bc132 100644 --- a/core/metadata_service.py +++ b/core/metadata_service.py @@ -1,7 +1,8 @@ """ -Metadata Service - Hot-swappable Spotify/iTunes provider +Metadata Service - Hot-swappable Spotify/iTunes/Deezer provider -Automatically uses Spotify when authenticated, falls back to iTunes when not. +Automatically uses Spotify when authenticated, falls back to the configured +fallback source (iTunes or Deezer) when not. Provides unified interface for all metadata operations. """ @@ -15,63 +16,83 @@ logger = get_logger("metadata_service") MetadataProvider = Literal["spotify", "itunes", "auto"] +def _get_configured_fallback_source(): + """Get the configured metadata fallback source ('itunes' or 'deezer').""" + try: + from config.settings import config_manager + return config_manager.get('metadata.fallback_source', 'itunes') or 'itunes' + except Exception: + return 'itunes' + + +def _create_fallback_client(): + """Create the configured fallback metadata client.""" + source = _get_configured_fallback_source() + if source == 'deezer': + from core.deezer_client import DeezerClient + return DeezerClient() + return iTunesClient() + + class MetadataService: """ - Unified metadata service that seamlessly switches between Spotify and iTunes. - + Unified metadata service that seamlessly switches between Spotify and + the configured fallback source (iTunes or Deezer). + Usage: service = MetadataService() tracks = service.search_tracks("Radiohead OK Computer") - # Uses Spotify if authenticated, otherwise iTunes + # Uses Spotify if authenticated, otherwise configured fallback """ - + def __init__(self, preferred_provider: MetadataProvider = "auto"): """ Initialize metadata service. - + Args: preferred_provider: "spotify", "itunes", or "auto" (default) - - "auto": Use Spotify if authenticated, else iTunes + - "auto": Use Spotify if authenticated, else configured fallback - "spotify": Always use Spotify (may fail if not authenticated) - - "itunes": Always use iTunes + - "itunes": Always use configured fallback source """ self.preferred_provider = preferred_provider self.spotify = SpotifyClient() - self.itunes = iTunesClient() - + self._fallback_source = _get_configured_fallback_source() + self.itunes = _create_fallback_client() # May be iTunesClient or DeezerClient + self._log_initialization() - + def _log_initialization(self): """Log initialization status""" spotify_status = "✅ Authenticated" if self.spotify.is_spotify_authenticated() else "❌ Not authenticated" - itunes_status = "✅ Available" if self.itunes.is_authenticated() else "❌ Not available" - - logger.info(f"MetadataService initialized - Spotify: {spotify_status}, iTunes: {itunes_status}") + fallback_status = "✅ Available" if self.itunes.is_authenticated() else "❌ Not available" + + logger.info(f"MetadataService initialized - Spotify: {spotify_status}, {self._fallback_source.capitalize()}: {fallback_status}") logger.info(f"Preferred provider: {self.preferred_provider}") - + def get_active_provider(self) -> str: """ Get the currently active metadata provider. Returns: - "spotify" or "itunes" + "spotify" or the configured fallback source name """ if self.preferred_provider == "spotify": return "spotify" elif self.preferred_provider == "itunes": - return "itunes" + return self._fallback_source else: # auto # Use is_spotify_authenticated() to check actual Spotify auth status - # (is_authenticated() always returns True due to iTunes fallback) - return "spotify" if self.spotify.is_spotify_authenticated() else "itunes" - + # (is_authenticated() always returns True due to fallback) + return "spotify" if self.spotify.is_spotify_authenticated() else self._fallback_source + def _get_client(self): """Get the appropriate client based on provider selection""" provider = self.get_active_provider() if provider == "spotify": if not self.spotify.is_spotify_authenticated(): - logger.warning("Spotify requested but not authenticated, falling back to iTunes") + logger.warning(f"Spotify requested but not authenticated, falling back to {self._fallback_source}") return self.itunes return self.spotify else: @@ -200,6 +221,7 @@ class MetadataService: "active_provider": self.get_active_provider(), "spotify_authenticated": self.spotify.is_spotify_authenticated(), "itunes_available": self.itunes.is_authenticated(), + "fallback_source": self._fallback_source, "preferred_provider": self.preferred_provider, "can_access_user_data": self.spotify.is_spotify_authenticated(), } @@ -208,7 +230,13 @@ class MetadataService: """Reload configuration for both clients""" logger.info("Reloading metadata service configuration") self.spotify.reload_config() - self.itunes.reload_config() + # Re-create fallback client in case the setting changed + new_source = _get_configured_fallback_source() + if new_source != self._fallback_source: + self._fallback_source = new_source + self.itunes = _create_fallback_client() + elif hasattr(self.itunes, 'reload_config'): + self.itunes.reload_config() self._log_initialization() diff --git a/core/personalized_playlists.py b/core/personalized_playlists.py index 5aa2bf94..7352214c 100644 --- a/core/personalized_playlists.py +++ b/core/personalized_playlists.py @@ -951,8 +951,8 @@ class PersonalizedPlaylistsService: logger.warning(f"Error getting albums for {artist.get('name', artist['id'])}: {e}") continue else: - from core.itunes_client import iTunesClient - itunes = iTunesClient() + from core.metadata_service import _create_fallback_client + itunes = _create_fallback_client() for artist in artists_for_albums: try: albums = itunes.get_artist_albums(artist['id'], limit=10) @@ -1007,8 +1007,8 @@ class PersonalizedPlaylistsService: logger.warning(f"Error getting tracks from album: {e}") continue else: - from core.itunes_client import iTunesClient - itunes = iTunesClient() + from core.metadata_service import _create_fallback_client + itunes = _create_fallback_client() for album in selected_albums: try: album_data = itunes.get_album(album.id, include_tracks=True) diff --git a/core/repair_worker.py b/core/repair_worker.py index d444459a..6cb4eb75 100644 --- a/core/repair_worker.py +++ b/core/repair_worker.py @@ -146,10 +146,10 @@ class RepairWorker: def itunes_client(self): if self._itunes_client is None: try: - from core.itunes_client import iTunesClient - self._itunes_client = iTunesClient() + from core.metadata_service import _create_fallback_client + self._itunes_client = _create_fallback_client() except Exception as e: - logger.error("Failed to initialize iTunesClient: %s", e) + logger.error("Failed to initialize fallback metadata client: %s", e) return self._itunes_client @property diff --git a/core/seasonal_discovery.py b/core/seasonal_discovery.py index d0bfa029..fd8f43b9 100644 --- a/core/seasonal_discovery.py +++ b/core/seasonal_discovery.py @@ -448,6 +448,10 @@ class SeasonalDiscoveryService: # IMPROVED: Sample 20 random watchlist artists (up from 10) for more variety sampled_artists = random.sample(watchlist_artists, min(20, len(watchlist_artists))) + from core.metadata_service import _create_fallback_client, _get_configured_fallback_source + fallback_client = _create_fallback_client() + fallback_source = _get_configured_fallback_source() + for artist in sampled_artists: try: albums = [] @@ -457,14 +461,14 @@ class SeasonalDiscoveryService: album_type='album,single,ep', limit=50 ) or [] - elif hasattr(artist, 'itunes_artist_id') and artist.itunes_artist_id: - from core.itunes_client import iTunesClient - itunes_client = iTunesClient() - albums = itunes_client.get_artist_albums( - artist.itunes_artist_id, - album_type='album,single,ep', - limit=50 - ) or [] + elif not use_spotify: + artist_id = getattr(artist, 'deezer_artist_id', None) if fallback_source == 'deezer' else getattr(artist, 'itunes_artist_id', None) + if artist_id: + albums = fallback_client.get_artist_albums( + artist_id, + album_type='album,single,ep', + limit=50 + ) or [] # Filter albums by seasonal keywords in title for album in albums: @@ -547,13 +551,13 @@ class SeasonalDiscoveryService: logger.debug(f"Error searching Spotify for '{keyword}': {e}") continue else: - # iTunes fallback - from core.itunes_client import iTunesClient - itunes_client = iTunesClient() + # Fallback metadata source (iTunes or Deezer) + from core.metadata_service import _create_fallback_client + fallback_client = _create_fallback_client() for keyword in search_keywords: try: - search_results = itunes_client.search_albums(keyword, limit=20) + search_results = fallback_client.search_albums(keyword, limit=20) for album in search_results: if album.id in seen_album_ids: @@ -765,6 +769,9 @@ class SeasonalDiscoveryService: seasonal_albums = self.get_seasonal_albums(season_key, limit=50, source=source) use_spotify = self.spotify_client and self.spotify_client.is_authenticated() + if not use_spotify: + from core.metadata_service import _create_fallback_client + fallback_client = _create_fallback_client() for album in seasonal_albums: try: @@ -774,10 +781,7 @@ class SeasonalDiscoveryService: if use_spotify: album_data = self.spotify_client.get_album(album_id) else: - # iTunes fallback - from core.itunes_client import iTunesClient - itunes_client = iTunesClient() - album_data = itunes_client.get_album(album_id) + album_data = fallback_client.get_album(album_id) if not album_data or 'tracks' not in album_data: continue diff --git a/core/spotify_client.py b/core/spotify_client.py index 176e8fe4..3320b889 100644 --- a/core/spotify_client.py +++ b/core/spotify_client.py @@ -407,6 +407,7 @@ class SpotifyClient: self.sp: Optional[spotipy.Spotify] = None self.user_id: Optional[str] = None self._itunes_client = None # Lazy-loaded iTunes fallback + self._deezer_client = None # Lazy-loaded Deezer fallback self._auth_cache_lock = threading.Lock() self._auth_cached_result: Optional[bool] = None self._auth_cache_time: float = 0 @@ -414,27 +415,51 @@ class SpotifyClient: self._setup_client() def _is_spotify_id(self, id_str: str) -> bool: - """Check if an ID is a Spotify ID (alphanumeric) vs iTunes ID (numeric only)""" + """Check if an ID is a Spotify ID (alphanumeric) vs a fallback source ID (numeric only)""" if not id_str: return False - # Spotify IDs contain letters and numbers, iTunes IDs are purely numeric + # Spotify IDs contain letters and numbers; iTunes/Deezer IDs are purely numeric return not id_str.isdigit() def _is_itunes_id(self, id_str: str) -> bool: - """Check if an ID is an iTunes ID (numeric only)""" + """Check if an ID is numeric (iTunes or Deezer format, not Spotify)""" if not id_str: return False return id_str.isdigit() @property def _itunes(self): - """Lazy-load iTunes client for fallback when Spotify not authenticated""" + """Lazy-load iTunes client""" if self._itunes_client is None: from core.itunes_client import iTunesClient self._itunes_client = iTunesClient() logger.info("iTunes fallback client initialized") return self._itunes_client + @property + def _deezer(self): + """Lazy-load Deezer client for metadata fallback""" + if self._deezer_client is None: + from core.deezer_client import DeezerClient + self._deezer_client = DeezerClient() + logger.info("Deezer fallback client initialized") + return self._deezer_client + + @property + def _fallback_source(self) -> str: + """Get configured metadata fallback source ('itunes' or 'deezer')""" + try: + return config_manager.get('metadata.fallback_source', 'itunes') or 'itunes' + except Exception: + return 'itunes' + + @property + def _fallback(self): + """Get the active fallback metadata client based on settings""" + if self._fallback_source == 'deezer': + return self._deezer + return self._itunes + def reload_config(self): """Reload configuration and re-initialize client""" self._invalidate_auth_cache() @@ -471,14 +496,14 @@ class SpotifyClient: def is_authenticated(self) -> bool: """ Check if client can service metadata requests. - Returns True if Spotify is authenticated OR iTunes fallback is available. + Returns True if Spotify is authenticated OR fallback (iTunes/Deezer) is available. For Spotify-specific auth check, use is_spotify_authenticated(). """ # If Spotify is authenticated, we're good if self.is_spotify_authenticated(): return True - # iTunes fallback is always available + # Fallback (iTunes or Deezer) is always available — no auth required return True def _invalidate_auth_cache(self): @@ -920,24 +945,23 @@ class SpotifyClient: @rate_limited def search_tracks(self, query: str, limit: int = 10) -> List[Track]: - """Search for tracks - falls back to iTunes if Spotify not authenticated""" + """Search for tracks - falls back to configured metadata source if Spotify not authenticated""" cache = get_metadata_cache() use_spotify = self.is_spotify_authenticated() - source = 'spotify' if use_spotify else 'itunes' - - # Check search cache - cached_results = cache.get_search_results(source, 'track', query, min(limit, 10)) - if cached_results is not None: - tracks = [] - for raw in cached_results: - try: - tracks.append(Track.from_spotify_track(raw) if source == 'spotify' else Track.from_itunes_track(raw)) - except Exception: - pass - if tracks: - return tracks if use_spotify: + # Check Spotify cache + cached_results = cache.get_search_results('spotify', 'track', query, min(limit, 10)) + if cached_results is not None: + tracks = [] + for raw in cached_results: + try: + tracks.append(Track.from_spotify_track(raw)) + except Exception: + pass + if tracks: + return tracks + try: results = self.sp.search(q=query, type='track', limit=min(limit, 10)) tracks = [] @@ -958,32 +982,32 @@ class SpotifyClient: except Exception as e: logger.error(f"Error searching tracks via Spotify: {e}") - # Fall through to iTunes fallback + # Fall through to fallback - # iTunes fallback - logger.debug(f"Using iTunes fallback for track search: {query}") - return self._itunes.search_tracks(query, limit) + # Fallback (iTunes or Deezer — configured in settings) + logger.debug(f"Using {self._fallback_source} fallback for track search: {query}") + return self._fallback.search_tracks(query, limit) @rate_limited def search_artists(self, query: str, limit: int = 10) -> List[Artist]: - """Search for artists - falls back to iTunes if Spotify not authenticated""" + """Search for artists - falls back to configured metadata source if Spotify not authenticated""" cache = get_metadata_cache() use_spotify = self.is_spotify_authenticated() - source = 'spotify' if use_spotify else 'itunes' - # Check search cache - cached_results = cache.get_search_results(source, 'artist', query, min(limit, 10)) - if cached_results is not None: - artists = [] - for raw in cached_results: - try: - artists.append(Artist.from_spotify_artist(raw) if source == 'spotify' else Artist.from_itunes_artist(raw)) - except Exception: - pass - if artists: - query_lower = query.lower().strip() - artists.sort(key=lambda a: (0 if a.name.lower().strip() == query_lower else 1)) - return artists + if use_spotify: + # Check Spotify cache + cached_results = cache.get_search_results('spotify', 'artist', query, min(limit, 10)) + if cached_results is not None: + artists = [] + for raw in cached_results: + try: + artists.append(Artist.from_spotify_artist(raw)) + except Exception: + pass + if artists: + query_lower = query.lower().strip() + artists.sort(key=lambda a: (0 if a.name.lower().strip() == query_lower else 1)) + return artists if use_spotify: try: @@ -1013,31 +1037,31 @@ class SpotifyClient: logger.error(f"Error searching artists via Spotify: {e}") # Fall through to iTunes fallback - # iTunes fallback - logger.debug(f"Using iTunes fallback for artist search: {query}") - artists = self._itunes.search_artists(query, limit) + # Fallback (iTunes or Deezer) + logger.debug(f"Using {self._fallback_source} fallback for artist search: {query}") + artists = self._fallback.search_artists(query, limit) query_lower = query.lower().strip() artists.sort(key=lambda a: (0 if a.name.lower().strip() == query_lower else 1)) return artists @rate_limited def search_albums(self, query: str, limit: int = 10) -> List[Album]: - """Search for albums - falls back to iTunes if Spotify not authenticated""" + """Search for albums - falls back to configured metadata source if Spotify not authenticated""" cache = get_metadata_cache() use_spotify = self.is_spotify_authenticated() - source = 'spotify' if use_spotify else 'itunes' - # Check search cache - cached_results = cache.get_search_results(source, 'album', query, min(limit, 10)) - if cached_results is not None: - albums = [] - for raw in cached_results: - try: - albums.append(Album.from_spotify_album(raw) if source == 'spotify' else Album.from_itunes_album(raw)) - except Exception: - pass - if albums: - return albums + if use_spotify: + # Check Spotify cache + cached_results = cache.get_search_results('spotify', 'album', query, min(limit, 10)) + if cached_results is not None: + albums = [] + for raw in cached_results: + try: + albums.append(Album.from_spotify_album(raw)) + except Exception: + pass + if albums: + return albums if use_spotify: try: @@ -1062,16 +1086,17 @@ class SpotifyClient: logger.error(f"Error searching albums via Spotify: {e}") # Fall through to iTunes fallback - # iTunes fallback - logger.debug(f"Using iTunes fallback for album search: {query}") - return self._itunes.search_albums(query, limit) + # Fallback (iTunes or Deezer) + logger.debug(f"Using {self._fallback_source} fallback for album search: {query}") + return self._fallback.search_albums(query, limit) @rate_limited def get_track_details(self, track_id: str) -> Optional[Dict[str, Any]]: - """Get detailed track information - falls back to iTunes if Spotify not authenticated""" + """Get detailed track information - falls back to configured metadata source""" # Check cache — we store raw track_data, reconstruct enhanced on hit cache = get_metadata_cache() - source = 'itunes' if self._is_itunes_id(track_id) else 'spotify' + fallback_src = self._fallback_source + source = fallback_src if self._is_itunes_id(track_id) else 'spotify' cached = cache.get_entity(source, 'track', track_id) if cached: if source == 'spotify': @@ -1081,8 +1106,8 @@ class SpotifyClient: # Simplified track cached by get_album_tracks — treat as cache miss logger.debug(f"Cache hit for track {track_id} lacks album data, fetching full data") else: - # iTunes cache hit — delegate to iTunesClient which reconstructs enhanced format - return self._itunes.get_track_details(track_id) + # Fallback cache hit — delegate to fallback client which reconstructs enhanced format + return self._fallback.get_track_details(track_id) if self.is_spotify_authenticated(): try: @@ -1100,13 +1125,13 @@ class SpotifyClient: logger.error(f"Error fetching track details via Spotify: {e}") # Fall through to iTunes fallback - # iTunes fallback - only if ID is numeric (iTunes format) + # Fallback - only if ID is numeric (non-Spotify format) if self._is_itunes_id(track_id): - logger.debug(f"Using iTunes fallback for track details: {track_id}") - result = self._itunes.get_track_details(track_id) + logger.debug(f"Using {fallback_src} fallback for track details: {track_id}") + result = self._fallback.get_track_details(track_id) return result else: - logger.debug(f"Cannot use iTunes fallback for Spotify track ID: {track_id}") + logger.debug(f"Cannot use fallback for Spotify track ID: {track_id}") return None @staticmethod @@ -1158,10 +1183,11 @@ class SpotifyClient: @rate_limited def get_album(self, album_id: str) -> Optional[Dict[str, Any]]: - """Get album information - falls back to iTunes if Spotify not authenticated""" + """Get album information - falls back to configured metadata source""" # Check cache first cache = get_metadata_cache() - source = 'itunes' if self._is_itunes_id(album_id) else 'spotify' + fallback_src = self._fallback_source + source = fallback_src if self._is_itunes_id(album_id) else 'spotify' cached = cache.get_entity(source, 'album', album_id) if cached: if source == 'spotify': @@ -1171,8 +1197,8 @@ class SpotifyClient: # Simplified album cached by get_artist_albums — treat as cache miss logger.debug(f"Cache hit for album {album_id} lacks tracks, fetching full data") else: - # iTunes cache hit — delegate to iTunesClient which reconstructs Spotify-compatible format - return self._itunes.get_album(album_id) + # Fallback cache hit — delegate to fallback client + return self._fallback.get_album(album_id) if self.is_spotify_authenticated(): try: @@ -1184,23 +1210,23 @@ class SpotifyClient: except Exception as e: _detect_and_set_rate_limit(e, 'get_album') logger.error(f"Error fetching album via Spotify: {e}") - # Fall through to iTunes fallback + # Fall through to fallback - # iTunes fallback - only if ID is numeric (iTunes format) + # Fallback - only if ID is numeric (non-Spotify format) if self._is_itunes_id(album_id): - logger.debug(f"Using iTunes fallback for album: {album_id}") - result = self._itunes.get_album(album_id) - return result + logger.debug(f"Using {fallback_src} fallback for album: {album_id}") + return self._fallback.get_album(album_id) else: - logger.debug(f"Cannot use iTunes fallback for Spotify album ID: {album_id}") + logger.debug(f"Cannot use fallback for Spotify album ID: {album_id}") return None @rate_limited def get_album_tracks(self, album_id: str) -> Optional[Dict[str, Any]]: - """Get album tracks - falls back to iTunes if Spotify not authenticated""" + """Get album tracks - falls back to configured metadata source""" # Cache key uses album_id with '_tracks' suffix to differentiate from album metadata cache = get_metadata_cache() - source = 'itunes' if self._is_itunes_id(album_id) else 'spotify' + fallback_src = self._fallback_source + source = fallback_src if self._is_itunes_id(album_id) else 'spotify' cache_key = f"{album_id}_tracks" cached = cache.get_entity(source, 'album', cache_key) if cached: @@ -1251,13 +1277,13 @@ class SpotifyClient: logger.error(f"Error fetching album tracks via Spotify: {e}") # Fall through to iTunes fallback - # iTunes fallback - only if ID is numeric (iTunes format) + # Fallback - only if ID is numeric (non-Spotify format) if self._is_itunes_id(album_id): - logger.debug(f"Using iTunes fallback for album tracks: {album_id}") - result = self._itunes.get_album_tracks(album_id) + logger.debug(f"Using {fallback_src} fallback for album tracks: {album_id}") + result = self._fallback.get_album_tracks(album_id) return result else: - logger.debug(f"Cannot use iTunes fallback for Spotify album ID: {album_id}") + logger.debug(f"Cannot use fallback for Spotify album ID: {album_id}") return None @rate_limited @@ -1293,12 +1319,12 @@ class SpotifyClient: logger.error(f"Error fetching artist albums via Spotify: {e}") # Fall through to iTunes fallback - # iTunes fallback - only if ID is numeric (iTunes format) + # Fallback - only if ID is numeric (non-Spotify format) if self._is_itunes_id(artist_id): - logger.debug(f"Using iTunes fallback for artist albums: {artist_id}") - return self._itunes.get_artist_albums(artist_id, album_type, limit) + logger.debug(f"Using {self._fallback_source} fallback for artist albums: {artist_id}") + return self._fallback.get_artist_albums(artist_id, album_type, limit) else: - logger.debug(f"Cannot use iTunes fallback for Spotify artist ID: {artist_id}") + logger.debug(f"Cannot use fallback for Spotify artist ID: {artist_id}") return [] @rate_limited @@ -1315,23 +1341,24 @@ class SpotifyClient: @rate_limited def get_artist(self, artist_id: str) -> Optional[Dict[str, Any]]: """ - Get full artist details - falls back to iTunes if Spotify not authenticated. + Get full artist details - falls back to configured metadata source. Args: - artist_id: Artist ID (Spotify or iTunes depending on authentication) + artist_id: Artist ID (Spotify or fallback source depending on authentication) Returns: Dictionary with artist data including images, genres, popularity """ # Check cache first (works even during rate limit bans) cache = get_metadata_cache() - source = 'itunes' if self._is_itunes_id(artist_id) else 'spotify' + fallback_src = self._fallback_source + source = fallback_src if self._is_itunes_id(artist_id) else 'spotify' cached = cache.get_entity(source, 'artist', artist_id) if cached: if source == 'spotify': return cached # Spotify raw format is the expected format - # iTunes cache hit — delegate to iTunesClient which reconstructs Spotify-compatible format - return self._itunes.get_artist(artist_id) + # Fallback cache hit — delegate to fallback client which reconstructs Spotify-compatible format + return self._fallback.get_artist(artist_id) if self.is_spotify_authenticated(): try: @@ -1344,13 +1371,12 @@ class SpotifyClient: logger.error(f"Error fetching artist via Spotify: {e}") # Fall through to iTunes fallback - # iTunes fallback - only if ID is numeric (iTunes format) + # Fallback - only if ID is numeric (non-Spotify format) if self._is_itunes_id(artist_id): - logger.debug(f"Using iTunes fallback for artist: {artist_id}") - result = self._itunes.get_artist(artist_id) - return result + logger.debug(f"Using {fallback_src} fallback for artist: {artist_id}") + return self._fallback.get_artist(artist_id) else: - logger.debug(f"Cannot use iTunes fallback for Spotify artist ID: {artist_id}") + logger.debug(f"Cannot use fallback for Spotify artist ID: {artist_id}") return None @rate_limited diff --git a/core/watchlist_scanner.py b/core/watchlist_scanner.py index 0a6d9759..0a9f5cc7 100644 --- a/core/watchlist_scanner.py +++ b/core/watchlist_scanner.py @@ -29,6 +29,21 @@ ITUNES_MAX_RETRIES = 3 ITUNES_BASE_DELAY = 1.0 # Base delay in seconds for exponential backoff +def _get_fallback_metadata_client(): + """Get the configured metadata fallback client (iTunes or Deezer).""" + try: + from config.settings import config_manager + source = config_manager.get('metadata.fallback_source', 'itunes') or 'itunes' + if source == 'deezer': + from core.deezer_client import DeezerClient + return DeezerClient(), 'deezer' + from core.itunes_client import iTunesClient + return iTunesClient(), 'itunes' + except Exception: + from core.itunes_client import iTunesClient + return iTunesClient(), 'itunes' + + def itunes_api_call_with_retry(func, *args, max_retries=ITUNES_MAX_RETRIES, **kwargs): """ Execute an iTunes API call with exponential backoff retry logic. @@ -402,29 +417,38 @@ class WatchlistScanner: else: logger.warning(f"No Spotify ID for {watchlist_artist.artist_name}, cannot scan with Spotify") return (None, None, None) - else: # itunes - if watchlist_artist.itunes_artist_id: - return (self.metadata_service.itunes, watchlist_artist.itunes_artist_id, 'itunes') + else: # itunes or deezer fallback + fallback_source = provider # 'itunes' or 'deezer' + fallback_client = self.metadata_service.itunes # May be iTunesClient or DeezerClient + # Pick the right stored ID for the active fallback source + stored_id = watchlist_artist.deezer_artist_id if fallback_source == 'deezer' else watchlist_artist.itunes_artist_id + if stored_id: + return (fallback_client, stored_id, fallback_source) else: - # No iTunes ID stored - search by name and cache it - logger.info(f"No iTunes ID for {watchlist_artist.artist_name}, searching by name...") + # No ID stored for this source - search by name and cache it + logger.info(f"No {fallback_source} ID for {watchlist_artist.artist_name}, searching by name...") try: - itunes_client = self.metadata_service.itunes - search_results = itunes_client.search_artists(watchlist_artist.artist_name, limit=1) + search_results = fallback_client.search_artists(watchlist_artist.artist_name, limit=1) if search_results and len(search_results) > 0: - itunes_id = search_results[0].id - logger.info(f"Found iTunes ID {itunes_id} for {watchlist_artist.artist_name}") - # Cache the iTunes ID in the database for future use - self.database.update_watchlist_artist_itunes_id( - watchlist_artist.spotify_artist_id or str(watchlist_artist.id), - itunes_id - ) - return (itunes_client, itunes_id, 'itunes') + found_id = search_results[0].id + logger.info(f"Found {fallback_source} ID {found_id} for {watchlist_artist.artist_name}") + # Cache the ID in the database for future use + if fallback_source == 'deezer': + self.database.update_watchlist_artist_deezer_id( + watchlist_artist.spotify_artist_id or str(watchlist_artist.id), + found_id + ) + else: + self.database.update_watchlist_artist_itunes_id( + watchlist_artist.spotify_artist_id or str(watchlist_artist.id), + found_id + ) + return (fallback_client, found_id, fallback_source) else: - logger.warning(f"Could not find {watchlist_artist.artist_name} on iTunes") + logger.warning(f"Could not find {watchlist_artist.artist_name} on {fallback_source}") return (None, None, None) except Exception as e: - logger.error(f"Error searching iTunes for {watchlist_artist.artist_name}: {e}") + logger.error(f"Error searching {fallback_source} for {watchlist_artist.artist_name}: {e}") return (None, None, None) def get_active_client_and_artist_id(self, watchlist_artist: WatchlistArtist): @@ -1551,13 +1575,12 @@ class WatchlistScanner: logger.info(f"Found {len(similar_artist_names)} similar artists from MusicMap") - # Get iTunes client for matching - from core.itunes_client import iTunesClient - itunes_client = iTunesClient() + # Get fallback metadata client for matching (iTunes or Deezer) + itunes_client, fallback_source = _get_fallback_metadata_client() # Get the searched artist's IDs to exclude them searched_spotify_id = None - searched_itunes_id = None + searched_fallback_id = None try: # Try Spotify search if self.spotify_client and self.spotify_client.is_spotify_authenticated(): @@ -1568,14 +1591,14 @@ class WatchlistScanner: logger.debug(f"Could not get searched artist Spotify ID: {e}") try: - # Try iTunes search - itunes_results = itunes_client.search_artists(artist_name, limit=1) - if itunes_results and len(itunes_results) > 0: - searched_itunes_id = itunes_results[0].id + # Try fallback source (iTunes/Deezer) search + fallback_results = itunes_client.search_artists(artist_name, limit=1) + if fallback_results and len(fallback_results) > 0: + searched_fallback_id = fallback_results[0].id except Exception as e: - logger.debug(f"Could not get searched artist iTunes ID: {e}") + logger.debug(f"Could not get searched artist {fallback_source} ID: {e}") - # Match each artist to both Spotify and iTunes + # Match each artist to both Spotify and fallback source (iTunes/Deezer) matched_artists = [] seen_names = set() # Track seen artist names to prevent duplicates @@ -1590,6 +1613,7 @@ class WatchlistScanner: 'name': artist_name_to_match, 'spotify_id': None, 'itunes_id': None, + 'deezer_id': None, 'image_url': None, 'genres': [], 'popularity': 0 @@ -1611,42 +1635,48 @@ class WatchlistScanner: except Exception as e: logger.debug(f"Spotify match failed for {artist_name_to_match}: {e}") - # Try to match on iTunes (with retry for rate limiting) + # Try to match on fallback source (iTunes/Deezer) with retry for rate limiting try: - itunes_results = itunes_api_call_with_retry( + fallback_results = itunes_api_call_with_retry( itunes_client.search_artists, artist_name_to_match, limit=1 ) - if itunes_results and len(itunes_results) > 0: - itunes_artist = itunes_results[0] + if fallback_results and len(fallback_results) > 0: + fallback_artist = fallback_results[0] # Skip if this is the searched artist - if itunes_artist.id != searched_itunes_id: - artist_data['itunes_id'] = itunes_artist.id - # Use iTunes name if we don't have Spotify + if fallback_artist.id != searched_fallback_id: + # Store under the appropriate key based on fallback source + if fallback_source == 'deezer': + artist_data['deezer_id'] = fallback_artist.id + else: + artist_data['itunes_id'] = fallback_artist.id + # Use fallback name if we don't have Spotify if not artist_data['spotify_id']: - artist_data['name'] = itunes_artist.name - # Use iTunes genres if we don't have Spotify genres - if not artist_data['genres'] and hasattr(itunes_artist, 'genres'): - artist_data['genres'] = itunes_artist.genres + artist_data['name'] = fallback_artist.name + # Use fallback genres if we don't have Spotify genres + if not artist_data['genres'] and hasattr(fallback_artist, 'genres'): + artist_data['genres'] = fallback_artist.genres else: - logger.info(f" [iTunes] No match found for: {artist_name_to_match}") + logger.info(f" [{fallback_source}] No match found for: {artist_name_to_match}") except Exception as e: - logger.info(f" [iTunes] Match failed for {artist_name_to_match}: {e}") + logger.info(f" [{fallback_source}] Match failed for {artist_name_to_match}: {e}") # Only add if we got at least one ID - if artist_data['spotify_id'] or artist_data['itunes_id']: + fallback_id_key = 'deezer_id' if fallback_source == 'deezer' else 'itunes_id' + if artist_data['spotify_id'] or artist_data.get(fallback_id_key): seen_names.add(name_lower) matched_artists.append(artist_data) - logger.debug(f" Matched: {artist_data['name']} (Spotify: {artist_data['spotify_id']}, iTunes: {artist_data['itunes_id']})") + logger.debug(f" Matched: {artist_data['name']} (Spotify: {artist_data['spotify_id']}, {fallback_source}: {artist_data.get(fallback_id_key)})") except Exception as match_error: logger.debug(f"Error matching {artist_name_to_match}: {match_error}") continue # Log detailed matching statistics - itunes_matched = sum(1 for a in matched_artists if a.get('itunes_id')) + fallback_id_key = 'deezer_id' if fallback_source == 'deezer' else 'itunes_id' + fallback_matched = sum(1 for a in matched_artists if a.get(fallback_id_key)) spotify_matched = sum(1 for a in matched_artists if a.get('spotify_id')) - both_matched = sum(1 for a in matched_artists if a.get('itunes_id') and a.get('spotify_id')) - logger.info(f"Matched {len(matched_artists)} similar artists - iTunes: {itunes_matched}, Spotify: {spotify_matched}, Both: {both_matched}") + both_matched = sum(1 for a in matched_artists if a.get(fallback_id_key) and a.get('spotify_id')) + logger.info(f"Matched {len(matched_artists)} similar artists - {fallback_source}: {fallback_matched}, Spotify: {spotify_matched}, Both: {both_matched}") return matched_artists except requests.exceptions.RequestException as e: @@ -1669,40 +1699,42 @@ class WatchlistScanner: Number of similar artists updated with iTunes IDs """ try: - # Get similar artists that are missing iTunes IDs - similar_artists = self.database.get_similar_artists_missing_itunes_ids(source_artist_id, profile_id=profile_id) + # Get fallback metadata client (iTunes or Deezer) + fallback_client, fallback_source = _get_fallback_metadata_client() + + # Get similar artists that are missing IDs for the active fallback source + similar_artists = self.database.get_similar_artists_missing_fallback_ids(source_artist_id, fallback_source, profile_id=profile_id) if not similar_artists: return 0 - logger.info(f"Backfilling iTunes IDs for {len(similar_artists)} similar artists") - - # Get iTunes client - from core.itunes_client import iTunesClient - itunes_client = iTunesClient() + logger.info(f"Backfilling {fallback_source} IDs for {len(similar_artists)} similar artists") updated_count = 0 for similar_artist in similar_artists: try: - # Search iTunes by artist name - itunes_results = itunes_client.search_artists(similar_artist.similar_artist_name, limit=1) - if itunes_results and len(itunes_results) > 0: - itunes_id = itunes_results[0].id - # Update the similar artist with the iTunes ID - if self.database.update_similar_artist_itunes_id(similar_artist.id, itunes_id): + results = fallback_client.search_artists(similar_artist.similar_artist_name, limit=1) + if results and len(results) > 0: + found_id = results[0].id + # Update the similar artist with the correct source ID + if fallback_source == 'deezer': + success = self.database.update_similar_artist_deezer_id(similar_artist.id, found_id) + else: + success = self.database.update_similar_artist_itunes_id(similar_artist.id, found_id) + if success: updated_count += 1 - logger.debug(f" Backfilled iTunes ID {itunes_id} for {similar_artist.similar_artist_name}") + logger.debug(f" Backfilled {fallback_source} ID {found_id} for {similar_artist.similar_artist_name}") except Exception as e: - logger.debug(f" Could not backfill iTunes ID for {similar_artist.similar_artist_name}: {e}") + logger.debug(f" Could not backfill {fallback_source} ID for {similar_artist.similar_artist_name}: {e}") continue if updated_count > 0: - logger.info(f"Backfilled {updated_count} similar artists with iTunes IDs") + logger.info(f"Backfilled {updated_count} similar artists with {fallback_source} IDs") return updated_count except Exception as e: - logger.error(f"Error backfilling similar artists iTunes IDs: {e}") + logger.error(f"Error backfilling similar artists {fallback_source if 'fallback_source' in dir() else 'fallback'} IDs: {e}") return 0 def update_similar_artists(self, watchlist_artist: WatchlistArtist, limit: int = 10, profile_id: int = 1) -> bool: @@ -1730,7 +1762,7 @@ class WatchlistScanner: stored_count = 0 for rank, similar_artist in enumerate(similar_artists, 1): try: - # similar_artist has 'name', 'spotify_id', 'itunes_id', 'image_url', 'genres', 'popularity' + # similar_artist has 'name', 'spotify_id', 'itunes_id', 'deezer_id', 'image_url', 'genres', 'popularity' success = self.database.add_or_update_similar_artist( source_artist_id=source_artist_id, similar_artist_name=similar_artist['name'], @@ -1740,12 +1772,15 @@ class WatchlistScanner: profile_id=profile_id, image_url=similar_artist.get('image_url'), genres=similar_artist.get('genres'), - popularity=similar_artist.get('popularity', 0) + popularity=similar_artist.get('popularity', 0), + similar_artist_deezer_id=similar_artist.get('deezer_id') ) if success: stored_count += 1 - logger.debug(f" #{rank}: {similar_artist['name']} (Spotify: {similar_artist.get('spotify_id')}, iTunes: {similar_artist.get('itunes_id')})") + fallback_id = similar_artist.get('deezer_id') or similar_artist.get('itunes_id') + fallback_label = 'Deezer' if similar_artist.get('deezer_id') else 'iTunes' + logger.debug(f" #{rank}: {similar_artist['name']} (Spotify: {similar_artist.get('spotify_id')}, {fallback_label}: {fallback_id})") except Exception as e: logger.warning(f"Error storing similar artist {similar_artist.get('name', 'Unknown')}: {e}") @@ -1795,16 +1830,15 @@ class WatchlistScanner: # Determine which sources are available spotify_available = self.spotify_client and self.spotify_client.is_spotify_authenticated() - # Import iTunes client for fallback - from core.itunes_client import iTunesClient - itunes_client = iTunesClient() - itunes_available = True # iTunes is always available (no auth needed) + # Import fallback metadata client (iTunes or Deezer) + itunes_client, fallback_source = _get_fallback_metadata_client() + fallback_available = True # Fallback source is always available (no auth needed) - if not spotify_available and not itunes_available: + if not spotify_available and not fallback_available: logger.warning("No music sources available to populate discovery pool") return - logger.info(f"Sources available - Spotify: {spotify_available}, iTunes: {itunes_available}") + logger.info(f"Sources available - Spotify: {spotify_available}, {fallback_source}: {fallback_available}") # Get top similar artists for this profile's watchlist (ordered by occurrence_count) similar_artists = self.database.get_top_similar_artists(limit=top_artists_limit, profile_id=profile_id) @@ -1834,25 +1868,28 @@ class WatchlistScanner: progress_callback('artist', f'{similar_artist.similar_artist_name} ({artist_idx}/{len(similar_artists)})') # Build list of sources to process for this artist - # iTunes is ALWAYS processed (baseline), Spotify is added if authenticated + # Fallback source (iTunes/Deezer) is ALWAYS processed (baseline), Spotify is added if authenticated sources_to_process = [] - # Always add iTunes first (baseline source) - itunes_id = similar_artist.similar_artist_itunes_id - if not itunes_id: - # On-the-fly lookup for missing iTunes ID (seamless provider switching) + # Always add fallback source first (baseline source) + fallback_id = similar_artist.similar_artist_itunes_id if fallback_source == 'itunes' else getattr(similar_artist, 'similar_artist_deezer_id', None) + if not fallback_id: + # On-the-fly lookup for missing fallback ID (seamless provider switching) try: - itunes_results = itunes_client.search_artists(similar_artist.similar_artist_name, limit=1) - if itunes_results and len(itunes_results) > 0: - itunes_id = itunes_results[0].id + fallback_results = itunes_client.search_artists(similar_artist.similar_artist_name, limit=1) + if fallback_results and len(fallback_results) > 0: + fallback_id = fallback_results[0].id # Cache it for future use - self.database.update_similar_artist_itunes_id(similar_artist.id, itunes_id) - logger.debug(f" Resolved iTunes ID {itunes_id} for {similar_artist.similar_artist_name}") + if fallback_source == 'deezer': + self.database.update_similar_artist_deezer_id(similar_artist.id, fallback_id) + else: + self.database.update_similar_artist_itunes_id(similar_artist.id, fallback_id) + logger.debug(f" Resolved {fallback_source} ID {fallback_id} for {similar_artist.similar_artist_name}") except Exception as e: - logger.debug(f" Could not resolve iTunes ID for {similar_artist.similar_artist_name}: {e}") + logger.debug(f" Could not resolve {fallback_source} ID for {similar_artist.similar_artist_name}: {e}") - if itunes_id: - sources_to_process.append(('itunes', itunes_id)) + if fallback_id: + sources_to_process.append((fallback_source, fallback_id)) # Add Spotify if authenticated and we have an ID if spotify_available and similar_artist.similar_artist_spotify_id: @@ -1874,7 +1911,7 @@ class WatchlistScanner: album_type='album,single,ep', limit=50 ) - else: # itunes + else: # itunes or deezer fallback all_albums = itunes_client.get_artist_albums( artist_id, album_type='album,single,ep', @@ -1892,7 +1929,7 @@ class WatchlistScanner: artist_data = self.spotify_client.get_artist(artist_id) if artist_data and 'genres' in artist_data: artist_genres = artist_data['genres'] - else: # iTunes - genres from artist lookup + else: # itunes/deezer - genres from artist lookup artist_data = itunes_client.get_artist(artist_id) if artist_data and 'genres' in artist_data: artist_genres = artist_data['genres'] @@ -1938,7 +1975,7 @@ class WatchlistScanner: if not album_data or 'tracks' not in album_data: continue tracks = album_data['tracks'].get('items', []) - else: # itunes + else: # itunes or deezer fallback album_data = itunes_client.get_album(album.id) if not album_data: continue @@ -1978,9 +2015,9 @@ class WatchlistScanner: } # Build track data for discovery pool with source-specific IDs - # iTunes has no popularity data — synthesize from recency + occurrence + # iTunes/Deezer have no popularity data — synthesize from recency + occurrence raw_popularity = album_data.get('popularity', 0) - if source == 'itunes' and raw_popularity == 0: + if source in ('itunes', 'deezer') and raw_popularity == 0: # Base 45, boost by recency and artist occurrence count synth_pop = 45 if is_new: @@ -2022,6 +2059,10 @@ class WatchlistScanner: track_data['spotify_track_id'] = track.get('id') track_data['spotify_album_id'] = album_data.get('id') track_data['spotify_artist_id'] = similar_artist.similar_artist_spotify_id + elif source == 'deezer': + track_data['deezer_track_id'] = track.get('id') + track_data['deezer_album_id'] = album_data.get('id') + track_data['deezer_artist_id'] = getattr(similar_artist, 'similar_artist_deezer_id', None) else: # itunes track_data['itunes_track_id'] = track.get('id') track_data['itunes_album_id'] = album_data.get('id') @@ -2100,22 +2141,22 @@ class WatchlistScanner: except Exception as e: logger.debug(f"Spotify search failed for {album_row['title']}: {e}") - # Fall back to iTunes if Spotify didn't work - if not tracks and itunes_available: + # Fall back to fallback source (iTunes/Deezer) if Spotify didn't work + if not tracks and fallback_available: try: search_results = itunes_client.search_albums(query, limit=1) if search_results and len(search_results) > 0: - itunes_album = search_results[0] - album_data = itunes_client.get_album(itunes_album.id) + fallback_album = search_results[0] + album_data = itunes_client.get_album(fallback_album.id) if album_data: - tracks_data = itunes_client.get_album_tracks(itunes_album.id) + tracks_data = itunes_client.get_album_tracks(fallback_album.id) tracks = tracks_data.get('items', []) if tracks_data else [] - db_source = 'itunes' - # For iTunes, artist ID is in the album data + db_source = fallback_source + # Artist ID is in the album data if album_data.get('artists'): artist_id_for_genres = album_data['artists'][0].get('id') except Exception as e: - logger.debug(f"iTunes search failed for {album_row['title']}: {e}") + logger.debug(f"{fallback_source} search failed for {album_row['title']}: {e}") if not tracks or not album_data: continue @@ -2177,6 +2218,10 @@ class WatchlistScanner: track_data['spotify_track_id'] = track.get('id') track_data['spotify_album_id'] = album_data.get('id') track_data['spotify_artist_id'] = artist_id_for_genres or '' + elif db_source == 'deezer': + track_data['deezer_track_id'] = track.get('id') + track_data['deezer_album_id'] = album_data.get('id') + track_data['deezer_artist_id'] = artist_id_for_genres or '' else: # itunes track_data['itunes_track_id'] = track.get('id') track_data['itunes_album_id'] = album_data.get('id') @@ -2395,24 +2440,23 @@ class WatchlistScanner: # 30-day window for recent releases cutoff_date = datetime.now() - timedelta(days=30) - cached_count = {'spotify': 0, 'itunes': 0} + cached_count = {'spotify': 0, 'itunes': 0, 'deezer': 0} albums_checked = 0 # Determine available sources spotify_available = self.spotify_client and self.spotify_client.is_spotify_authenticated() - # Get iTunes client - from core.itunes_client import iTunesClient - itunes_client = iTunesClient() + # Get fallback metadata client (iTunes or Deezer) + itunes_client, fallback_source = _get_fallback_metadata_client() # Get artists to check (scoped to profile) watchlist_artists = self.database.get_watchlist_artists(profile_id=profile_id) similar_artists = self.database.get_top_similar_artists(limit=50, profile_id=profile_id) logger.info(f"Checking albums from {len(watchlist_artists)} watchlist + {len(similar_artists)} similar artists") - logger.info(f"Sources: Spotify={spotify_available}, iTunes=True") + logger.info(f"Sources: Spotify={spotify_available}, {fallback_source}=True") - def process_album(album, artist_name, artist_spotify_id, artist_itunes_id, source): + def process_album(album, artist_name, artist_spotify_id, artist_itunes_id, source, artist_deezer_id=None): """Helper to process and cache a single album""" nonlocal albums_checked try: @@ -2422,7 +2466,7 @@ class WatchlistScanner: if not release_str: return False - # Handle iTunes ISO format (2017-12-08T08:00:00Z) + # Handle iTunes/Deezer ISO format (2017-12-08T08:00:00Z) if 'T' in release_str: release_str = release_str.split('T')[0] @@ -2432,10 +2476,12 @@ class WatchlistScanner: album_data = { 'album_spotify_id': album.id if source == 'spotify' else None, 'album_itunes_id': album.id if source == 'itunes' else None, + 'album_deezer_id': album.id if source == 'deezer' else None, 'album_name': album.name, 'artist_name': artist_name, 'artist_spotify_id': artist_spotify_id, 'artist_itunes_id': artist_itunes_id, + 'artist_deezer_id': artist_deezer_id, 'album_cover_url': album.image_url if hasattr(album, 'image_url') else None, 'release_date': release_str[:10], 'album_type': album.album_type if hasattr(album, 'album_type') else 'album' @@ -2449,39 +2495,44 @@ class WatchlistScanner: return False # Track resolution stats - itunes_resolved = 0 - itunes_failed_resolve = 0 + fallback_resolved = 0 + fallback_failed_resolve = 0 # Process watchlist artists for artist in watchlist_artists: - # Always process iTunes (baseline) - itunes_id = artist.itunes_artist_id - if not itunes_id: - # Try to resolve iTunes ID on-the-fly (with retry for rate limiting) + # Always process fallback source (iTunes or Deezer) as baseline + fallback_id = artist.itunes_artist_id if fallback_source == 'itunes' else artist.deezer_artist_id + if not fallback_id: + # Try to resolve fallback ID on-the-fly (with retry for rate limiting) try: results = itunes_api_call_with_retry( itunes_client.search_artists, artist.artist_name, limit=1 ) if results and len(results) > 0: - itunes_id = results[0].id - itunes_resolved += 1 - logger.debug(f"[iTunes] Resolved ID for {artist.artist_name}: {itunes_id}") + fallback_id = results[0].id + fallback_resolved += 1 + logger.debug(f"[{fallback_source}] Resolved ID for {artist.artist_name}: {fallback_id}") else: - itunes_failed_resolve += 1 - logger.info(f"[iTunes] No artist found for: {artist.artist_name}") + fallback_failed_resolve += 1 + logger.info(f"[{fallback_source}] No artist found for: {artist.artist_name}") except Exception as e: - itunes_failed_resolve += 1 - logger.info(f"[iTunes] Failed to resolve {artist.artist_name}: {e}") + fallback_failed_resolve += 1 + logger.info(f"[{fallback_source}] Failed to resolve {artist.artist_name}: {e}") - if itunes_id: + if fallback_id: try: albums = itunes_api_call_with_retry( - itunes_client.get_artist_albums, itunes_id, album_type='album,single,ep', limit=20 + itunes_client.get_artist_albums, fallback_id, album_type='album,single,ep', limit=20 ) for album in albums or []: - process_album(album, artist.artist_name, artist.spotify_artist_id, itunes_id, 'itunes') + process_album( + album, artist.artist_name, artist.spotify_artist_id, + fallback_id if fallback_source == 'itunes' else None, + fallback_source, + artist_deezer_id=fallback_id if fallback_source == 'deezer' else None + ) except Exception as e: - logger.info(f"[iTunes] Error fetching albums for {artist.artist_name}: {e}") + logger.info(f"[{fallback_source}] Error fetching albums for {artist.artist_name}: {e}") # Process Spotify if authenticated if spotify_available and artist.spotify_artist_id: @@ -2492,7 +2543,7 @@ class WatchlistScanner: limit=20 ) for album in albums or []: - process_album(album, artist.artist_name, artist.spotify_artist_id, itunes_id, 'spotify') + process_album(album, artist.artist_name, artist.spotify_artist_id, fallback_id if fallback_source == 'itunes' else None, 'spotify') except Exception as e: logger.debug(f"Error fetching Spotify albums for {artist.artist_name}: {e}") @@ -2500,36 +2551,44 @@ class WatchlistScanner: # Process similar artists for artist in similar_artists: - # Always process iTunes (baseline) - itunes_id = artist.similar_artist_itunes_id - if not itunes_id: - # Try to resolve iTunes ID on-the-fly (with retry for rate limiting) + # Always process fallback source (iTunes or Deezer) as baseline + fallback_id = artist.similar_artist_itunes_id if fallback_source == 'itunes' else getattr(artist, 'similar_artist_deezer_id', None) + if not fallback_id: + # Try to resolve fallback ID on-the-fly (with retry for rate limiting) try: results = itunes_api_call_with_retry( itunes_client.search_artists, artist.similar_artist_name, limit=1 ) if results and len(results) > 0: - itunes_id = results[0].id + fallback_id = results[0].id # Cache for future - self.database.update_similar_artist_itunes_id(artist.id, itunes_id) - itunes_resolved += 1 - logger.debug(f"[iTunes] Resolved ID for similar artist {artist.similar_artist_name}: {itunes_id}") + if fallback_source == 'deezer': + self.database.update_similar_artist_deezer_id(artist.id, fallback_id) + else: + self.database.update_similar_artist_itunes_id(artist.id, fallback_id) + fallback_resolved += 1 + logger.debug(f"[{fallback_source}] Resolved ID for similar artist {artist.similar_artist_name}: {fallback_id}") else: - itunes_failed_resolve += 1 - logger.info(f"[iTunes] No artist found for similar: {artist.similar_artist_name}") + fallback_failed_resolve += 1 + logger.info(f"[{fallback_source}] No artist found for similar: {artist.similar_artist_name}") except Exception as e: - itunes_failed_resolve += 1 - logger.info(f"[iTunes] Failed to resolve similar {artist.similar_artist_name}: {e}") + fallback_failed_resolve += 1 + logger.info(f"[{fallback_source}] Failed to resolve similar {artist.similar_artist_name}: {e}") - if itunes_id: + if fallback_id: try: albums = itunes_api_call_with_retry( - itunes_client.get_artist_albums, itunes_id, album_type='album,single,ep', limit=20 + itunes_client.get_artist_albums, fallback_id, album_type='album,single,ep', limit=20 ) for album in albums or []: - process_album(album, artist.similar_artist_name, artist.similar_artist_spotify_id, itunes_id, 'itunes') + process_album( + album, artist.similar_artist_name, artist.similar_artist_spotify_id, + fallback_id if fallback_source == 'itunes' else None, + fallback_source, + artist_deezer_id=fallback_id if fallback_source == 'deezer' else None + ) except Exception as e: - logger.info(f"[iTunes] Error fetching albums for similar {artist.similar_artist_name}: {e}") + logger.info(f"[{fallback_source}] Error fetching albums for similar {artist.similar_artist_name}: {e}") # Process Spotify if authenticated if spotify_available and artist.similar_artist_spotify_id: @@ -2540,15 +2599,15 @@ class WatchlistScanner: limit=20 ) for album in albums or []: - process_album(album, artist.similar_artist_name, artist.similar_artist_spotify_id, itunes_id, 'spotify') + process_album(album, artist.similar_artist_name, artist.similar_artist_spotify_id, fallback_id if fallback_source == 'itunes' else None, 'spotify') except Exception as e: logger.debug(f"Error fetching Spotify albums for {artist.similar_artist_name}: {e}") time.sleep(DELAY_BETWEEN_ARTISTS) - total_cached = cached_count['spotify'] + cached_count['itunes'] - logger.info(f"Cached {total_cached} recent albums (Spotify: {cached_count['spotify']}, iTunes: {cached_count['itunes']}) from {albums_checked} albums checked") - logger.info(f"[iTunes] ID resolution stats: {itunes_resolved} resolved, {itunes_failed_resolve} failed") + total_cached = cached_count['spotify'] + cached_count.get(fallback_source, 0) + logger.info(f"Cached {total_cached} recent albums (Spotify: {cached_count['spotify']}, {fallback_source}: {cached_count.get(fallback_source, 0)}) from {albums_checked} albums checked") + logger.info(f"[{fallback_source}] ID resolution stats: {fallback_resolved} resolved, {fallback_failed_resolve} failed") except Exception as e: logger.error(f"Error caching discovery recent albums: {e}") @@ -2566,16 +2625,15 @@ class WatchlistScanner: try: import random from datetime import datetime - from core.itunes_client import iTunesClient logger.info("Curating discovery playlists...") # Determine available sources spotify_available = self.spotify_client and self.spotify_client.is_spotify_authenticated() - itunes_client = iTunesClient() + itunes_client, fallback_source = _get_fallback_metadata_client() # Process each available source - sources_to_process = ['itunes'] # iTunes always available + sources_to_process = [fallback_source] # Fallback source (iTunes/Deezer) always available if spotify_available: sources_to_process.append('spotify') @@ -2609,7 +2667,12 @@ class WatchlistScanner: for album in albums: try: # Get album data from appropriate source - album_id = album.get('album_spotify_id') if source == 'spotify' else album.get('album_itunes_id') + if source == 'spotify': + album_id = album.get('album_spotify_id') + elif source == 'deezer': + album_id = album.get('album_deezer_id') + else: + album_id = album.get('album_itunes_id') if not album_id: continue @@ -2641,8 +2704,8 @@ class WatchlistScanner: # Calculate track score recency_score = max(0, 100 - (days_old * 7)) popularity_score = track.get('popularity', album_data.get('popularity', 0)) - # iTunes has no popularity — use recency-based synthetic score - if source == 'itunes' and popularity_score == 0: + # iTunes/Deezer have no popularity — use recency-based synthetic score + if source in ('itunes', 'deezer') and popularity_score == 0: popularity_score = max(40, 70 - days_old) is_single = album.get('album_type', 'album') == 'single' single_bonus = 20 if is_single else 0 @@ -2703,6 +2766,9 @@ class WatchlistScanner: if source == 'spotify': formatted_track['spotify_track_id'] = track_data['id'] formatted_track['spotify_album_id'] = track_data['album'].get('id', '') + elif source == 'deezer': + formatted_track['deezer_track_id'] = track_data['id'] + formatted_track['deezer_album_id'] = track_data['album'].get('id', '') else: formatted_track['itunes_track_id'] = track_data['id'] formatted_track['itunes_album_id'] = track_data['album'].get('id', '') @@ -2758,13 +2824,15 @@ class WatchlistScanner: discovery_weekly_tracks.append(track.spotify_track_id) elif source == 'itunes' and track.itunes_track_id: discovery_weekly_tracks.append(track.itunes_track_id) + elif source == 'deezer' and track.deezer_track_id: + discovery_weekly_tracks.append(track.deezer_track_id) playlist_key = f'discovery_weekly_{source}' self.database.save_curated_playlist(playlist_key, discovery_weekly_tracks, profile_id=profile_id) logger.info(f"Discovery Weekly ({source}) curated: {len(discovery_weekly_tracks)} tracks") # Also save without suffix for backward compatibility (use active source) - active_source = 'spotify' if spotify_available else 'itunes' + active_source = 'spotify' if spotify_available else fallback_source release_radar_key = f'release_radar_{active_source}' discovery_weekly_key = f'discovery_weekly_{active_source}' diff --git a/database/music_database.py b/database/music_database.py index 51f544c1..533fe379 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -87,6 +87,7 @@ class WatchlistArtist: updated_at: Optional[datetime] = None image_url: Optional[str] = None itunes_artist_id: Optional[str] = None # Cross-provider support + deezer_artist_id: Optional[str] = None # Cross-provider support include_albums: bool = True include_eps: bool = True include_singles: bool = True @@ -99,7 +100,7 @@ class WatchlistArtist: @dataclass class SimilarArtist: - """Similar artist recommendation from Spotify/iTunes""" + """Similar artist recommendation from Spotify/iTunes/Deezer""" id: int source_artist_id: str # Watchlist artist's database ID similar_artist_spotify_id: Optional[str] # Spotify artist ID (may be None if iTunes-only) @@ -111,6 +112,7 @@ class SimilarArtist: image_url: Optional[str] = None # Cached artist image genres: Optional[List[str]] = None # Cached genres popularity: int = 0 # Cached popularity score + similar_artist_deezer_id: Optional[str] = None # Deezer artist ID @dataclass class DiscoveryTrack: @@ -122,7 +124,10 @@ class DiscoveryTrack: itunes_track_id: Optional[str] # iTunes track ID (None if Spotify source) itunes_album_id: Optional[str] # iTunes album ID (None if Spotify source) itunes_artist_id: Optional[str] # iTunes artist ID (None if Spotify source) - source: str # 'spotify' or 'itunes' + deezer_track_id: Optional[str] # Deezer track ID (None if non-Deezer source) + deezer_album_id: Optional[str] # Deezer album ID (None if non-Deezer source) + deezer_artist_id: Optional[str] # Deezer artist ID (None if non-Deezer source) + source: str # 'spotify', 'itunes', or 'deezer' track_name: str artist_name: str album_name: str @@ -141,7 +146,8 @@ class RecentRelease: watchlist_artist_id: int album_spotify_id: Optional[str] # Spotify album ID (None if iTunes source) album_itunes_id: Optional[str] # iTunes album ID (None if Spotify source) - source: str # 'spotify' or 'itunes' + album_deezer_id: Optional[str] # Deezer album ID (None if non-Deezer source) + source: str # 'spotify', 'itunes', or 'deezer' album_name: str release_date: str album_cover_url: Optional[str] @@ -846,6 +852,13 @@ class MusicDatabase: cursor.execute("ALTER TABLE discovery_pool ADD COLUMN source TEXT DEFAULT 'spotify'") logger.info("Added iTunes columns to discovery_pool table for dual-source discovery") + # Migration: Add Deezer columns to discovery_pool for tri-source discovery + if 'deezer_track_id' not in discovery_pool_columns: + cursor.execute("ALTER TABLE discovery_pool ADD COLUMN deezer_track_id TEXT") + cursor.execute("ALTER TABLE discovery_pool ADD COLUMN deezer_album_id TEXT") + cursor.execute("ALTER TABLE discovery_pool ADD COLUMN deezer_artist_id TEXT") + logger.info("Added Deezer columns to discovery_pool table") + # Migration: Add iTunes ID to similar_artists for dual-source discovery cursor.execute("PRAGMA table_info(similar_artists)") similar_artists_columns = [column[1] for column in cursor.fetchall()] @@ -854,6 +867,10 @@ class MusicDatabase: cursor.execute("ALTER TABLE similar_artists ADD COLUMN similar_artist_itunes_id TEXT") logger.info("Added similar_artist_itunes_id column to similar_artists table") + if 'similar_artist_deezer_id' not in similar_artists_columns: + cursor.execute("ALTER TABLE similar_artists ADD COLUMN similar_artist_deezer_id TEXT") + logger.info("Added similar_artist_deezer_id column to similar_artists table") + # Migration: Add iTunes columns to recent_releases for dual-source discovery cursor.execute("PRAGMA table_info(recent_releases)") recent_releases_columns = [column[1] for column in cursor.fetchall()] @@ -863,6 +880,11 @@ class MusicDatabase: cursor.execute("ALTER TABLE recent_releases ADD COLUMN source TEXT DEFAULT 'spotify'") logger.info("Added iTunes columns to recent_releases table for dual-source discovery") + # Migration: Add Deezer column to recent_releases for tri-source discovery + if 'album_deezer_id' not in recent_releases_columns: + cursor.execute("ALTER TABLE recent_releases ADD COLUMN album_deezer_id TEXT") + logger.info("Added album_deezer_id column to recent_releases table") + # Migration: Add iTunes columns to discovery_recent_albums for dual-source discovery cursor.execute("PRAGMA table_info(discovery_recent_albums)") discovery_recent_albums_columns = [column[1] for column in cursor.fetchall()] @@ -873,6 +895,12 @@ class MusicDatabase: cursor.execute("ALTER TABLE discovery_recent_albums ADD COLUMN source TEXT DEFAULT 'spotify'") logger.info("Added iTunes columns to discovery_recent_albums table for dual-source discovery") + # Migration: Add Deezer columns to discovery_recent_albums for tri-source discovery + if 'album_deezer_id' not in discovery_recent_albums_columns: + cursor.execute("ALTER TABLE discovery_recent_albums ADD COLUMN album_deezer_id TEXT") + cursor.execute("ALTER TABLE discovery_recent_albums ADD COLUMN artist_deezer_id TEXT") + logger.info("Added Deezer columns to discovery_recent_albums table") + # Migration: Fix NOT NULL constraint on album_spotify_id (required for iTunes-only albums) # Check if album_spotify_id has NOT NULL constraint by checking table schema cursor.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='discovery_recent_albums'") @@ -973,6 +1001,8 @@ class MusicDatabase: cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_itunes_track ON discovery_pool (itunes_track_id)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_artist ON discovery_pool (spotify_artist_id)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_itunes_artist ON discovery_pool (itunes_artist_id)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_deezer_track ON discovery_pool (deezer_track_id)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_deezer_artist ON discovery_pool (deezer_artist_id)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_source ON discovery_pool (source)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_added_date ON discovery_pool (added_date)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_is_new ON discovery_pool (is_new_release)") @@ -1099,6 +1129,10 @@ class MusicDatabase: cursor.execute("ALTER TABLE watchlist_artists ADD COLUMN itunes_artist_id TEXT") logger.info("Added itunes_artist_id column to watchlist_artists table for cross-provider support") + if 'deezer_artist_id' not in columns: + cursor.execute("ALTER TABLE watchlist_artists ADD COLUMN deezer_artist_id TEXT") + logger.info("Added deezer_artist_id column to watchlist_artists table for cross-provider support") + except Exception as e: logger.error(f"Error adding itunes_artist_id column to watchlist_artists: {e}") # Don't raise - this is a migration, database can still function @@ -5428,28 +5462,63 @@ class MusicDatabase: return 0 # Watchlist operations - def add_artist_to_watchlist(self, artist_id: str, artist_name: str, profile_id: int = 1) -> bool: + def add_artist_to_watchlist(self, artist_id: str, artist_name: str, profile_id: int = 1, source: str = None) -> bool: """Add an artist to the watchlist for monitoring new releases. - Automatically detects if artist_id is a Spotify ID (alphanumeric) or iTunes ID (numeric). + Automatically detects if artist_id is a Spotify ID (alphanumeric) or iTunes/Deezer ID (numeric). + If the artist already exists (by name match), updates the existing row with the new source ID. """ try: with self._get_connection() as conn: cursor = conn.cursor() - # Detect ID type: iTunes IDs are purely numeric, Spotify IDs are alphanumeric - is_itunes_id = artist_id.isdigit() + # Check if artist already exists by name (case-insensitive) for this profile + cursor.execute(""" + SELECT id, spotify_artist_id, itunes_artist_id, deezer_artist_id + FROM watchlist_artists + WHERE LOWER(artist_name) = LOWER(?) AND profile_id = ? + LIMIT 1 + """, (artist_name, profile_id)) + existing = cursor.fetchone() - if is_itunes_id: + # Detect source: explicit source param, or infer from ID format + if not source: + source = 'itunes' if artist_id.isdigit() else 'spotify' + + if existing: + # Artist already on watchlist — update with new source ID if missing + col_map = {'spotify': 'spotify_artist_id', 'itunes': 'itunes_artist_id', 'deezer': 'deezer_artist_id'} + col = col_map.get(source) + if col and not existing[col]: + cursor.execute(f""" + UPDATE watchlist_artists + SET {col} = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, (artist_id, existing['id'])) + conn.commit() + logger.info(f"Updated existing watchlist artist '{artist_name}' with {source} ID: {artist_id}") + else: + logger.info(f"Artist '{artist_name}' already on watchlist (profile: {profile_id})") + return True + + # New artist — insert with the appropriate ID column + if source == 'deezer': cursor.execute(""" - INSERT OR REPLACE INTO watchlist_artists + INSERT INTO watchlist_artists + (deezer_artist_id, artist_name, date_added, updated_at, profile_id) + VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?) + """, (artist_id, artist_name, profile_id)) + logger.info(f"Added artist '{artist_name}' to watchlist (Deezer ID: {artist_id}, profile: {profile_id})") + elif source == 'itunes': + cursor.execute(""" + INSERT INTO watchlist_artists (itunes_artist_id, artist_name, date_added, updated_at, profile_id) VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?) """, (artist_id, artist_name, profile_id)) logger.info(f"Added artist '{artist_name}' to watchlist (iTunes ID: {artist_id}, profile: {profile_id})") else: cursor.execute(""" - INSERT OR REPLACE INTO watchlist_artists + INSERT INTO watchlist_artists (spotify_artist_id, artist_name, date_added, updated_at, profile_id) VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?) """, (artist_id, artist_name, profile_id)) @@ -5463,23 +5532,23 @@ class MusicDatabase: return False def remove_artist_from_watchlist(self, artist_id: str, profile_id: int = 1) -> bool: - """Remove an artist from the watchlist (checks both Spotify and iTunes IDs)""" + """Remove an artist from the watchlist (checks Spotify, iTunes, and Deezer IDs)""" try: with self._get_connection() as conn: cursor = conn.cursor() - # Get artist name for logging (check both ID columns) + # Get artist name for logging (check all ID columns) cursor.execute(""" SELECT artist_name FROM watchlist_artists - WHERE (spotify_artist_id = ? OR itunes_artist_id = ?) AND profile_id = ? - """, (artist_id, artist_id, profile_id)) + WHERE (spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ?) AND profile_id = ? + """, (artist_id, artist_id, artist_id, profile_id)) result = cursor.fetchone() artist_name = result['artist_name'] if result else "Unknown" cursor.execute(""" DELETE FROM watchlist_artists - WHERE (spotify_artist_id = ? OR itunes_artist_id = ?) AND profile_id = ? - """, (artist_id, artist_id, profile_id)) + WHERE (spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ?) AND profile_id = ? + """, (artist_id, artist_id, artist_id, profile_id)) if cursor.rowcount > 0: conn.commit() @@ -5493,18 +5562,25 @@ class MusicDatabase: logger.error(f"Error removing artist from watchlist (ID: {artist_id}): {e}") return False - def is_artist_in_watchlist(self, artist_id: str, profile_id: int = 1) -> bool: - """Check if an artist is currently in the watchlist (checks both Spotify and iTunes IDs)""" + def is_artist_in_watchlist(self, artist_id: str, profile_id: int = 1, artist_name: str = None) -> bool: + """Check if an artist is currently in the watchlist (checks Spotify, iTunes, Deezer IDs and name)""" try: with self._get_connection() as conn: cursor = conn.cursor() - # Check both spotify_artist_id and itunes_artist_id columns - cursor.execute(""" - SELECT 1 FROM watchlist_artists - WHERE (spotify_artist_id = ? OR itunes_artist_id = ?) AND profile_id = ? - LIMIT 1 - """, (artist_id, artist_id, profile_id)) + # Check all ID columns and optionally artist name + if artist_name: + cursor.execute(""" + SELECT 1 FROM watchlist_artists + WHERE (spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ? OR LOWER(artist_name) = LOWER(?)) AND profile_id = ? + LIMIT 1 + """, (artist_id, artist_id, artist_id, artist_name, profile_id)) + else: + cursor.execute(""" + SELECT 1 FROM watchlist_artists + WHERE (spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ?) AND profile_id = ? + LIMIT 1 + """, (artist_id, artist_id, artist_id, profile_id)) result = cursor.fetchone() return result is not None @@ -5526,7 +5602,7 @@ class MusicDatabase: # Build SELECT query based on existing columns base_columns = ['id', 'spotify_artist_id', 'artist_name', 'date_added', 'last_scan_timestamp', 'created_at', 'updated_at'] - optional_columns = ['image_url', 'itunes_artist_id', 'include_albums', 'include_eps', 'include_singles', + optional_columns = ['image_url', 'itunes_artist_id', 'deezer_artist_id', 'include_albums', 'include_eps', 'include_singles', 'include_live', 'include_remixes', 'include_acoustic', 'include_compilations', 'include_instrumentals'] @@ -5553,6 +5629,7 @@ class MusicDatabase: # Safely get optional columns with defaults (sqlite3.Row uses dict-style access) image_url = row['image_url'] if 'image_url' in existing_columns else None itunes_artist_id = row['itunes_artist_id'] if 'itunes_artist_id' in existing_columns else None + deezer_artist_id = row['deezer_artist_id'] if 'deezer_artist_id' in existing_columns else None include_albums = bool(row['include_albums']) if 'include_albums' in existing_columns else True include_eps = bool(row['include_eps']) if 'include_eps' in existing_columns else True include_singles = bool(row['include_singles']) if 'include_singles' in existing_columns else True @@ -5572,6 +5649,7 @@ class MusicDatabase: updated_at=datetime.fromisoformat(row['updated_at']) if row['updated_at'] else None, image_url=image_url, itunes_artist_id=itunes_artist_id, + deezer_artist_id=deezer_artist_id, include_albums=include_albums, include_eps=include_eps, include_singles=include_singles, @@ -5840,6 +5918,27 @@ class MusicDatabase: logger.error(f"Error caching watchlist iTunes ID: {e}") return False + def update_watchlist_artist_deezer_id(self, spotify_artist_id: str, deezer_id: str) -> bool: + """Update the Deezer artist ID for a watchlist artist by Spotify ID (for cross-provider caching)""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + + cursor.execute(""" + UPDATE watchlist_artists + SET deezer_artist_id = ?, updated_at = CURRENT_TIMESTAMP + WHERE spotify_artist_id = ? + """, (deezer_id, spotify_artist_id)) + + conn.commit() + if cursor.rowcount > 0: + logger.info(f"Cached Deezer ID {deezer_id} for Spotify artist {spotify_artist_id}") + return cursor.rowcount > 0 + + except Exception as e: + logger.error(f"Error caching watchlist Deezer ID: {e}") + return False + # === Discovery Feature Methods === def add_or_update_similar_artist(self, source_artist_id: str, similar_artist_name: str, @@ -5849,8 +5948,9 @@ class MusicDatabase: profile_id: int = 1, image_url: Optional[str] = None, genres: Optional[list] = None, - popularity: int = 0) -> bool: - """Add or update a similar artist recommendation (supports both Spotify and iTunes IDs)""" + popularity: int = 0, + similar_artist_deezer_id: Optional[str] = None) -> bool: + """Add or update a similar artist recommendation (supports Spotify, iTunes, and Deezer IDs)""" try: with self._get_connection() as conn: cursor = conn.cursor() @@ -5859,14 +5959,15 @@ class MusicDatabase: # Use artist name as the unique key (allows storing both IDs for same artist) cursor.execute(""" INSERT INTO similar_artists - (source_artist_id, similar_artist_spotify_id, similar_artist_itunes_id, similar_artist_name, + (source_artist_id, similar_artist_spotify_id, similar_artist_itunes_id, similar_artist_deezer_id, similar_artist_name, similarity_rank, occurrence_count, last_updated, profile_id, image_url, genres, popularity, metadata_updated_at) - VALUES (?, ?, ?, ?, ?, 1, CURRENT_TIMESTAMP, ?, ?, ?, ?, CURRENT_TIMESTAMP) + VALUES (?, ?, ?, ?, ?, ?, 1, CURRENT_TIMESTAMP, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(profile_id, source_artist_id, similar_artist_name) DO UPDATE SET similar_artist_spotify_id = COALESCE(excluded.similar_artist_spotify_id, similar_artist_spotify_id), similar_artist_itunes_id = COALESCE(excluded.similar_artist_itunes_id, similar_artist_itunes_id), + similar_artist_deezer_id = COALESCE(excluded.similar_artist_deezer_id, similar_artist_deezer_id), similarity_rank = excluded.similarity_rank, occurrence_count = occurrence_count + 1, last_updated = CURRENT_TIMESTAMP, @@ -5874,7 +5975,7 @@ class MusicDatabase: genres = COALESCE(excluded.genres, genres), popularity = CASE WHEN excluded.popularity > 0 THEN excluded.popularity ELSE popularity END, metadata_updated_at = CASE WHEN excluded.image_url IS NOT NULL THEN CURRENT_TIMESTAMP ELSE metadata_updated_at END - """, (source_artist_id, similar_artist_spotify_id, similar_artist_itunes_id, similar_artist_name, + """, (source_artist_id, similar_artist_spotify_id, similar_artist_itunes_id, similar_artist_deezer_id, similar_artist_name, similarity_rank, profile_id, image_url, genres_json, popularity)) conn.commit() @@ -5905,7 +6006,8 @@ class MusicDatabase: similar_artist_name=row['similar_artist_name'], similarity_rank=row['similarity_rank'], occurrence_count=row['occurrence_count'], - last_updated=datetime.fromisoformat(row['last_updated']) + last_updated=datetime.fromisoformat(row['last_updated']), + similar_artist_deezer_id=row['similar_artist_deezer_id'] if 'similar_artist_deezer_id' in row.keys() else None, ) for row in rows] except Exception as e: @@ -5914,14 +6016,19 @@ class MusicDatabase: def get_similar_artists_missing_itunes_ids(self, source_artist_id: str, profile_id: int = 1) -> List[SimilarArtist]: """Get similar artists for a source that are missing iTunes IDs (for backfill)""" + return self.get_similar_artists_missing_fallback_ids(source_artist_id, 'itunes', profile_id) + + def get_similar_artists_missing_fallback_ids(self, source_artist_id: str, fallback_source: str = 'itunes', profile_id: int = 1) -> List[SimilarArtist]: + """Get similar artists missing IDs for the given fallback source (for backfill)""" try: + col = 'similar_artist_deezer_id' if fallback_source == 'deezer' else 'similar_artist_itunes_id' with self._get_connection() as conn: cursor = conn.cursor() - cursor.execute(""" + cursor.execute(f""" SELECT * FROM similar_artists WHERE source_artist_id = ? AND profile_id = ? - AND (similar_artist_itunes_id IS NULL OR similar_artist_itunes_id = '') + AND ({col} IS NULL OR {col} = '') ORDER BY occurrence_count DESC LIMIT 50 """, (source_artist_id, profile_id)) @@ -5931,15 +6038,16 @@ class MusicDatabase: id=row['id'], source_artist_id=row['source_artist_id'], similar_artist_spotify_id=row['similar_artist_spotify_id'], - similar_artist_itunes_id=None, + similar_artist_itunes_id=row['similar_artist_itunes_id'] if 'similar_artist_itunes_id' in row.keys() else None, similar_artist_name=row['similar_artist_name'], similarity_rank=row['similarity_rank'], occurrence_count=row['occurrence_count'], - last_updated=datetime.fromisoformat(row['last_updated']) + last_updated=datetime.fromisoformat(row['last_updated']), + similar_artist_deezer_id=row['similar_artist_deezer_id'] if 'similar_artist_deezer_id' in row.keys() else None, ) for row in rows] except Exception as e: - logger.error(f"Error getting similar artists missing iTunes IDs: {e}") + logger.error(f"Error getting similar artists missing {fallback_source} IDs: {e}") return [] def update_similar_artist_itunes_id(self, similar_artist_id: int, itunes_id: str) -> bool: @@ -5961,6 +6069,25 @@ class MusicDatabase: logger.error(f"Error updating similar artist iTunes ID: {e}") return False + def update_similar_artist_deezer_id(self, similar_artist_id: int, deezer_id: str) -> bool: + """Update a similar artist's Deezer ID (for backfill)""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + + cursor.execute(""" + UPDATE similar_artists + SET similar_artist_deezer_id = ? + WHERE id = ? + """, (deezer_id, similar_artist_id)) + + conn.commit() + return cursor.rowcount > 0 + + except Exception as e: + logger.error(f"Error updating similar artist Deezer ID: {e}") + return False + def update_similar_artist_metadata(self, similar_artist_id: int, image_url: str = None, genres: list = None, popularity: int = None) -> bool: """Cache artist metadata (image, genres, popularity) to avoid repeated API calls""" @@ -5989,6 +6116,8 @@ class MusicDatabase: genres_json = json.dumps(genres) if genres else None if source == 'spotify': where_clause = "similar_artist_spotify_id = ?" + elif source == 'deezer': + where_clause = "similar_artist_deezer_id = ?" else: where_clause = "similar_artist_itunes_id = ?" cursor.execute(f""" @@ -6091,6 +6220,7 @@ class MusicDatabase: MAX(sa.source_artist_id) as source_artist_id, MAX(sa.similar_artist_spotify_id) as similar_artist_spotify_id, MAX(sa.similar_artist_itunes_id) as similar_artist_itunes_id, + MAX(sa.similar_artist_deezer_id) as similar_artist_deezer_id, sa.similar_artist_name, AVG(sa.similarity_rank) as similarity_rank, SUM(sa.occurrence_count) as occurrence_count, @@ -6102,6 +6232,7 @@ class MusicDatabase: LEFT JOIN watchlist_artists wa ON ( (sa.similar_artist_spotify_id IS NOT NULL AND sa.similar_artist_spotify_id = wa.spotify_artist_id) OR (sa.similar_artist_itunes_id IS NOT NULL AND sa.similar_artist_itunes_id = wa.itunes_artist_id) + OR (sa.similar_artist_deezer_id IS NOT NULL AND sa.similar_artist_deezer_id = wa.deezer_artist_id) OR LOWER(sa.similar_artist_name) = LOWER(wa.artist_name) ) AND wa.profile_id = ? WHERE wa.id IS NULL AND sa.profile_id = ? @@ -6127,6 +6258,7 @@ class MusicDatabase: source_artist_id=row['source_artist_id'], similar_artist_spotify_id=row['similar_artist_spotify_id'], similar_artist_itunes_id=row['similar_artist_itunes_id'] if 'similar_artist_itunes_id' in row.keys() else None, + similar_artist_deezer_id=row['similar_artist_deezer_id'] if 'similar_artist_deezer_id' in row.keys() else None, similar_artist_name=row['similar_artist_name'], similarity_rank=int(row['similarity_rank']), occurrence_count=row['occurrence_count'], @@ -6171,6 +6303,9 @@ class MusicDatabase: elif source == 'itunes' and track_data.get('itunes_track_id'): cursor.execute("SELECT COUNT(*) as count FROM discovery_pool WHERE itunes_track_id = ? AND source = 'itunes' AND profile_id = ?", (track_data['itunes_track_id'], profile_id)) + elif source == 'deezer' and track_data.get('deezer_track_id'): + cursor.execute("SELECT COUNT(*) as count FROM discovery_pool WHERE deezer_track_id = ? AND source = 'deezer' AND profile_id = ?", + (track_data['deezer_track_id'], profile_id)) else: # Fallback check by track name and artist cursor.execute("SELECT COUNT(*) as count FROM discovery_pool WHERE track_name = ? AND artist_name = ? AND source = ? AND profile_id = ?", @@ -6187,9 +6322,10 @@ class MusicDatabase: INSERT INTO discovery_pool (spotify_track_id, spotify_album_id, spotify_artist_id, itunes_track_id, itunes_album_id, itunes_artist_id, + deezer_track_id, deezer_album_id, deezer_artist_id, source, track_name, artist_name, album_name, album_cover_url, duration_ms, popularity, release_date, is_new_release, track_data_json, artist_genres, added_date, profile_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?) """, ( track_data.get('spotify_track_id'), track_data.get('spotify_album_id'), @@ -6197,6 +6333,9 @@ class MusicDatabase: track_data.get('itunes_track_id'), track_data.get('itunes_album_id'), track_data.get('itunes_artist_id'), + track_data.get('deezer_track_id'), + track_data.get('deezer_album_id'), + track_data.get('deezer_artist_id'), source, track_data['track_name'], track_data['artist_name'], @@ -6284,6 +6423,9 @@ class MusicDatabase: itunes_track_id=row['itunes_track_id'] if 'itunes_track_id' in row_keys else None, itunes_album_id=row['itunes_album_id'] if 'itunes_album_id' in row_keys else None, itunes_artist_id=row['itunes_artist_id'] if 'itunes_artist_id' in row_keys else None, + deezer_track_id=row['deezer_track_id'] if 'deezer_track_id' in row_keys else None, + deezer_album_id=row['deezer_album_id'] if 'deezer_album_id' in row_keys else None, + deezer_artist_id=row['deezer_artist_id'] if 'deezer_artist_id' in row_keys else None, source=row['source'] if 'source' in row_keys else 'spotify', track_name=row['track_name'], artist_name=row['artist_name'], @@ -6309,14 +6451,17 @@ class MusicDatabase: cursor.execute(""" INSERT OR REPLACE INTO discovery_recent_albums - (album_spotify_id, album_itunes_id, artist_spotify_id, artist_itunes_id, source, + (album_spotify_id, album_itunes_id, album_deezer_id, + artist_spotify_id, artist_itunes_id, artist_deezer_id, source, album_name, artist_name, album_cover_url, release_date, album_type, cached_date, profile_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?) """, ( album_data.get('album_spotify_id'), album_data.get('album_itunes_id'), + album_data.get('album_deezer_id'), album_data.get('artist_spotify_id'), album_data.get('artist_itunes_id'), + album_data.get('artist_deezer_id'), source, album_data['album_name'], album_data['artist_name'], @@ -6539,6 +6684,7 @@ class MusicDatabase: watchlist_artist_id=row['watchlist_artist_id'], album_spotify_id=row['album_spotify_id'], album_itunes_id=row['album_itunes_id'] if 'album_itunes_id' in row.keys() else None, + album_deezer_id=row['album_deezer_id'] if 'album_deezer_id' in row.keys() else None, source=row['source'] if 'source' in row.keys() else 'spotify', album_name=row['album_name'], release_date=row['release_date'], diff --git a/web_server.py b/web_server.py index 4ed1d66d..d1210e43 100644 --- a/web_server.py +++ b/web_server.py @@ -3147,11 +3147,12 @@ def run_service_test(service, test_config): if temp_client.is_spotify_authenticated(): return True, "Spotify connection successful!" else: - # Using iTunes fallback + # Using fallback metadata source + fallback_name = 'Deezer' if _get_metadata_fallback_source() == 'deezer' else 'Apple Music' if spotify_configured: - return True, "Apple Music connection successful! (Spotify configured but not authenticated)" + return True, f"{fallback_name} connection successful! (Spotify configured but not authenticated)" else: - return True, "Apple Music connection successful! (Spotify not configured)" + return True, f"{fallback_name} connection successful! (Spotify not configured)" else: return False, "Music service authentication failed. Check credentials and complete OAuth flow in browser if prompted." elif service == "tidal": @@ -3647,13 +3648,13 @@ def get_status(): if is_rate_limited or cooldown_remaining > 0: # During rate limit or post-ban cooldown, skip the auth probe entirely. # Probing Spotify here would reset the rate limit timer. - music_source = 'itunes' # App uses iTunes during ban — reflect truth + music_source = _get_metadata_fallback_source() # App uses fallback during ban spotify_response_time = 0 else: spotify_start = time.time() spotify_connected = spotify_client.is_spotify_authenticated() if spotify_client else False spotify_response_time = (time.time() - spotify_start) * 1000 - music_source = 'spotify' if spotify_connected else 'itunes' + music_source = 'spotify' if spotify_connected else _get_metadata_fallback_source() _status_cache['spotify'] = { 'connected': True, # Always true — iTunes fallback is always available @@ -4253,7 +4254,7 @@ def handle_settings(): if 'active_media_server' in new_settings: config_manager.set_active_media_server(new_settings['active_media_server']) - for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'tidal_download', 'qobuz', 'hifi_download', 'listenbrainz', 'acoustid', 'lastfm', 'genius', 'import', 'lossy_copy', 'ui_appearance', 'youtube', 'content_filter', 'itunes', 'm3u_export', 'musicbrainz', 'deezer', 'audiodb']: + for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'tidal_download', 'qobuz', 'hifi_download', 'listenbrainz', 'acoustid', 'lastfm', 'genius', 'import', 'lossy_copy', 'ui_appearance', 'youtube', 'content_filter', 'itunes', 'm3u_export', 'musicbrainz', 'deezer', 'audiodb', 'metadata']: if service in new_settings: for key, value in new_settings[service].items(): config_manager.set(f'{service}.{key}', value) @@ -4294,6 +4295,8 @@ def handle_settings(): genius_worker._init_client() if tidal_enrichment_worker: tidal_enrichment_worker.client = tidal_client + # Invalidate status cache so next poll reflects new settings (e.g. fallback source change) + _status_cache_timestamps['spotify'] = 0 print("✅ Service clients re-initialized with new settings.") return jsonify({"success": True, "message": "Settings saved successfully."}) except Exception as e: @@ -5101,7 +5104,7 @@ def test_connection_endpoint(): current_time = time.time() if service == 'spotify': _status_cache['spotify']['connected'] = True - _status_cache['spotify']['source'] = 'spotify' if (spotify_client and spotify_client.is_spotify_authenticated()) else 'itunes' + _status_cache['spotify']['source'] = 'spotify' if (spotify_client and spotify_client.is_spotify_authenticated()) else _get_metadata_fallback_source() _status_cache_timestamps['spotify'] = current_time print("✅ Updated Spotify status cache after successful test") elif service in ['plex', 'jellyfin', 'navidrome']: @@ -5151,7 +5154,7 @@ def test_dashboard_connection_endpoint(): current_time = time.time() if service == 'spotify': _status_cache['spotify']['connected'] = True - _status_cache['spotify']['source'] = 'spotify' if (spotify_client and spotify_client.is_spotify_authenticated()) else 'itunes' + _status_cache['spotify']['source'] = 'spotify' if (spotify_client and spotify_client.is_spotify_authenticated()) else _get_metadata_fallback_source() _status_cache_timestamps['spotify'] = current_time print("✅ Updated Spotify status cache after successful dashboard test") elif service in ['plex', 'jellyfin', 'navidrome']: @@ -5819,16 +5822,18 @@ def spotify_disconnect(): spotify_enrichment_worker.pause() spotify_client.disconnect() # Immediately update status cache so UI reflects the change + fallback_src = _get_metadata_fallback_source() _status_cache['spotify'] = { - 'connected': True, # iTunes fallback is always available + 'connected': True, # Fallback source is always available 'response_time': 0, - 'source': 'itunes', + 'source': fallback_src, 'rate_limited': False, 'rate_limit': None } _status_cache_timestamps['spotify'] = time.time() - add_activity_item("🔌", "Spotify Disconnected", "Switched to Apple Music/iTunes metadata source", "Now") - return jsonify({'success': True, 'message': 'Spotify disconnected. Now using Apple Music/iTunes.'}) + fallback_label = 'Deezer' if fallback_src == 'deezer' else 'Apple Music/iTunes' + add_activity_item("🔌", "Spotify Disconnected", f"Switched to {fallback_label} metadata source", "Now") + return jsonify({'success': True, 'message': f'Spotify disconnected. Now using {fallback_label}.'}) except Exception as e: logger.error(f"Error disconnecting Spotify: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @@ -8462,10 +8467,9 @@ def get_artist_image(artist_id): return jsonify({"success": True, "image_url": image_url}) return jsonify({"success": True, "image_url": None}) else: - # Use iTunes fallback - fetch album art - from core.itunes_client import iTunesClient - itunes = iTunesClient() - image_url = itunes._get_artist_image_from_albums(artist_id) + # Use fallback source - fetch album art + fallback = _get_metadata_fallback_client() + image_url = fallback._get_artist_image_from_albums(artist_id) return jsonify({"success": True, "image_url": image_url}) except Exception as e: print(f"Error fetching artist image: {e}") @@ -8485,9 +8489,9 @@ def get_artist_discography(artist_id): # Determine which source to use spotify_available = spotify_client and spotify_client.is_spotify_authenticated() - # Import iTunes client for fallback - from core.itunes_client import iTunesClient - itunes_client = iTunesClient() + # Import fallback client for non-Spotify lookups + fallback_client = _get_metadata_fallback_client() + fallback_source = _get_metadata_fallback_source() print(f"🎤 Fetching discography for artist: {artist_id} (name: {artist_name}, spotify: {spotify_available})") @@ -8518,36 +8522,36 @@ def get_artist_discography(artist_id): except Exception as e: print(f"Spotify lookup failed: {e}") - # Try iTunes if Spotify didn't work or if it's a numeric ID + # Try fallback source if Spotify didn't work or if it's a numeric ID if not albums: try: if is_numeric_id: - # It's an iTunes ID, use directly - albums = itunes_client.get_artist_albums(artist_id, album_type='album,single', limit=50) + # It's a numeric ID (iTunes/Deezer), use directly + albums = fallback_client.get_artist_albums(artist_id, album_type='album,single', limit=50) if albums: - active_source = 'itunes' - print(f"📊 Got {len(albums)} albums from iTunes (direct ID)") + active_source = fallback_source + print(f"📊 Got {len(albums)} albums from {fallback_source} (direct ID)") elif artist_name: - # Search iTunes by name - print(f"🔄 Trying iTunes search by name: '{artist_name}'") - itunes_artists = itunes_client.search_artists(artist_name, limit=5) - if itunes_artists: + # Search fallback by name + print(f"🔄 Trying {fallback_source} search by name: '{artist_name}'") + fallback_artists = fallback_client.search_artists(artist_name, limit=5) + if fallback_artists: # Find best match best_match = None - for artist in itunes_artists: + for artist in fallback_artists: if artist.name.lower() == artist_name.lower(): best_match = artist break if not best_match: - best_match = itunes_artists[0] + best_match = fallback_artists[0] - print(f"✅ Found iTunes artist: {best_match.name} (ID: {best_match.id})") - albums = itunes_client.get_artist_albums(best_match.id, album_type='album,single', limit=50) + print(f"✅ Found {fallback_source} artist: {best_match.name} (ID: {best_match.id})") + albums = fallback_client.get_artist_albums(best_match.id, album_type='album,single', limit=50) if albums: - active_source = 'itunes' - print(f"📊 Got {len(albums)} albums from iTunes (name search)") + active_source = fallback_source + print(f"📊 Got {len(albums)} albums from {fallback_source} (name search)") except Exception as e: - print(f"iTunes lookup failed: {e}") + print(f"{fallback_source} lookup failed: {e}") print(f"📊 Total albums returned: {len(albums)} (source: {active_source})") @@ -9612,11 +9616,10 @@ def enhance_artist_quality(artist_id): except Exception as e: print(f"⚠️ [Enhance] Search match failed for {title}: {e}") - # iTunes fallback when Spotify unavailable or no match found + # Fallback source when Spotify unavailable or no match found if not matched_track_data: try: - from core.itunes_client import iTunesClient - itunes_client = iTunesClient() + fallback_client = _get_metadata_fallback_client() itunes_best = None itunes_best_conf = 0.0 @@ -9629,7 +9632,7 @@ def enhance_artist_quality(artist_id): for search_query in itunes_queries[:3]: try: - itunes_results = itunes_client.search_tracks(search_query, limit=5) + itunes_results = fallback_client.search_tracks(search_query, limit=5) if not itunes_results: continue for it_track in itunes_results: @@ -9674,13 +9677,13 @@ def enhance_artist_quality(artist_id): 'preview_url': itunes_best.preview_url, 'external_urls': itunes_best.external_urls or {}, } - print(f"🍎 [Enhance] iTunes fallback match for {title}: {itunes_best.artists[0]} - {itunes_best.name} (conf: {itunes_best_conf:.3f})") + print(f"🍎 [Enhance] Fallback match for {title}: {itunes_best.artists[0]} - {itunes_best.name} (conf: {itunes_best_conf:.3f})") except Exception as e: - print(f"⚠️ [Enhance] iTunes fallback failed for {title}: {e}") + print(f"⚠️ [Enhance] Fallback source failed for {title}: {e}") if not matched_track_data: failed_count += 1 - failed_tracks.append({'track_id': track_id, 'title': title, 'reason': 'No Spotify or iTunes match'}) + failed_tracks.append({'track_id': track_id, 'title': title, 'reason': 'No Spotify or fallback match'}) continue # Add to wishlist with enhance source @@ -24267,10 +24270,9 @@ def search_itunes_tracks(): else: if hydrabase_worker and dev_mode_enabled: hydrabase_worker.enqueue(query, 'track') - from core.itunes_client import iTunesClient - itunes_client = iTunesClient() - tracks = itunes_client.search_tracks(query, limit=limit) - source = 'itunes' + fallback_client = _get_metadata_fallback_client() + tracks = fallback_client.search_tracks(query, limit=limit) + source = _get_metadata_fallback_source() tracks_dict = [{ 'id': t.id, @@ -24326,16 +24328,14 @@ def get_itunes_album_tracks(album_id): except Exception as e: logger.warning(f"Hydrabase album_tracks failed for '{album_id}', falling back to iTunes: {e}") - from core.itunes_client import iTunesClient - - itunes_client = iTunesClient() - album_data = itunes_client.get_album(album_id) + fallback_client = _get_metadata_fallback_client() + album_data = fallback_client.get_album(album_id) if not album_data: return jsonify({"error": "Album not found"}), 404 # Get tracks for this album - tracks_data = itunes_client.get_album_tracks(album_id) + tracks_data = fallback_client.get_album_tracks(album_id) tracks = tracks_data.get('items', []) if tracks_data else [] # Format response to match Spotify structure for frontend compatibility @@ -24348,12 +24348,12 @@ def get_itunes_album_tracks(album_id): 'album_type': album_data.get('album_type', 'album'), 'images': album_data.get('images', []), 'tracks': tracks, - 'source': 'itunes' + 'source': _get_metadata_fallback_source() } return jsonify(album_dict) except Exception as e: - logger.error(f"Error fetching iTunes album tracks: {e}") + logger.error(f"Error fetching album tracks: {e}") return jsonify({"error": str(e)}), 500 @@ -24423,15 +24423,14 @@ def get_discover_album(source, album_id): 'source': 'spotify' }) - elif source == 'itunes': - from core.itunes_client import iTunesClient - itunes_client = iTunesClient() + elif source in ('itunes', 'deezer'): + fallback_client = _get_metadata_fallback_client() - album_data = itunes_client.get_album(album_id) + album_data = fallback_client.get_album(album_id) if not album_data: return jsonify({"error": "Album not found"}), 404 - tracks_data = itunes_client.get_album_tracks(album_id) + tracks_data = fallback_client.get_album_tracks(album_id) tracks = tracks_data.get('items', []) if tracks_data else [] return jsonify({ @@ -24443,7 +24442,7 @@ def get_discover_album(source, album_id): 'album_type': album_data.get('album_type', 'album'), 'images': album_data.get('images', []), 'tracks': tracks, - 'source': 'itunes' + 'source': source }) else: @@ -25055,17 +25054,16 @@ def _run_playlist_discovery_worker(playlists, automation_id=None): try: _ew_state = _pause_enrichment_workers('mirrored playlist discovery') use_spotify = spotify_client and spotify_client.is_spotify_authenticated() - discovery_source = 'spotify' if use_spotify else 'itunes' + discovery_source = 'spotify' if use_spotify else _get_metadata_fallback_source() itunes_client_instance = None if not use_spotify: try: - from core.itunes_client import iTunesClient - itunes_client_instance = iTunesClient() + itunes_client_instance = _get_metadata_fallback_client() except Exception: - print("❌ Neither Spotify nor iTunes available for discovery") + print(f"❌ Neither Spotify nor {_get_metadata_fallback_source()} available for discovery") _update_automation_progress(automation_id, status='error', progress=100, - phase='Error', log_line='Neither Spotify nor iTunes available', + phase='Error', log_line=f'Neither Spotify nor {_get_metadata_fallback_source()} available', log_type='error') return @@ -25467,13 +25465,12 @@ def _run_tidal_discovery_worker(playlist_id): # Determine which provider to use use_spotify = spotify_client and spotify_client.is_spotify_authenticated() - discovery_source = 'spotify' if use_spotify else 'itunes' + discovery_source = 'spotify' if use_spotify else _get_metadata_fallback_source() - # Initialize iTunes client if needed + # Initialize fallback client if needed itunes_client_instance = None if not use_spotify: - from core.itunes_client import iTunesClient - itunes_client_instance = iTunesClient() + itunes_client_instance = _get_metadata_fallback_client() print(f"🎵 Starting Tidal discovery for: {playlist.name} (using {discovery_source.upper()})") @@ -25567,15 +25564,15 @@ def _run_tidal_discovery_worker(playlist_id): state['spotify_matches'] = successful_discoveries elif not use_spotify and track_result and isinstance(track_result, dict): - # iTunes: Function returns a dict with track data (includes 'confidence' key) + # Fallback: Function returns a dict with track data (includes 'confidence' key) match_confidence = track_result.pop('confidence', 0.80) match_data = track_result - match_data['source'] = 'itunes' - # Extract image URL from iTunes album images - _itunes_album = match_data.get('album', {}) - _itunes_images = _itunes_album.get('images', []) if isinstance(_itunes_album, dict) else [] - if _itunes_images and 'image_url' not in match_data: - match_data['image_url'] = _itunes_images[0].get('url', '') + match_data['source'] = discovery_source + # Extract image URL from album images + _fb_album = match_data.get('album', {}) + _fb_images = _fb_album.get('images', []) if isinstance(_fb_album, dict) else [] + if _fb_images and 'image_url' not in match_data: + match_data['image_url'] = _fb_images[0].get('url', '') result['spotify_data'] = match_data result['match_data'] = match_data result['status'] = 'found' @@ -25639,16 +25636,16 @@ def _run_tidal_discovery_worker(playlist_id): def _search_spotify_for_tidal_track(tidal_track, use_spotify=True, itunes_client=None): - """Search Spotify/iTunes for a Tidal track using matching_engine for better accuracy + """Search Spotify/fallback for a Tidal track using matching_engine for better accuracy Args: tidal_track: The Tidal track to search for - use_spotify: If True, use Spotify; if False, use iTunes - itunes_client: iTunes client instance (required when use_spotify=False) + use_spotify: If True, use Spotify; if False, use fallback source + itunes_client: Fallback client instance (required when use_spotify=False) Returns: For Spotify: (Track, raw_data, confidence) tuple or None - For iTunes: dict with track data (includes 'confidence' key) or None + For fallback: dict with track data (includes 'confidence' key) or None """ if use_spotify: if not spotify_client or not spotify_client.is_authenticated(): @@ -25667,7 +25664,7 @@ def _search_spotify_for_tidal_track(tidal_track, use_spotify=True, itunes_client artist_name = artists[0] # Use primary artist source_duration = getattr(tidal_track, 'duration_ms', 0) or 0 - source_name = "Spotify" if use_spotify else "iTunes" + source_name = "Spotify" if use_spotify else _get_metadata_fallback_source().capitalize() print(f"🔍 Tidal track: '{artist_name}' - '{track_name}' (searching {source_name})") @@ -25762,7 +25759,7 @@ def _search_spotify_for_tidal_track(tidal_track, use_spotify=True, itunes_client result_artists = best_match.artists if hasattr(best_match, 'artists') else [] result_artist = result_artists[0] if result_artists else 'Unknown' result_name = best_match.name if hasattr(best_match, 'name') else 'Unknown' - print(f"✅ Final Tidal iTunes match: {result_artist} - {result_name} (confidence: {best_confidence:.3f})") + print(f"✅ Final Tidal {source_name} match: {result_artist} - {result_name} (confidence: {best_confidence:.3f})") album_name = best_match.album if hasattr(best_match, 'album') else 'Unknown Album' image_url = best_match.image_url if hasattr(best_match, 'image_url') else '' @@ -25779,7 +25776,7 @@ def _search_spotify_for_tidal_track(tidal_track, use_spotify=True, itunes_client 'images': [{'url': image_url, 'height': 300, 'width': 300}] if image_url else [] }, 'duration_ms': duration_ms, - 'source': 'itunes', + 'source': _get_metadata_fallback_source(), 'confidence': best_confidence } else: @@ -25980,6 +25977,22 @@ def _get_deezer_client(): _deezer_client_instance = DeezerClient() return _deezer_client_instance +def _get_metadata_fallback_source(): + """Get the configured metadata fallback source ('itunes' or 'deezer').""" + try: + return config_manager.get('metadata.fallback_source', 'itunes') or 'itunes' + except Exception: + return 'itunes' + +def _get_metadata_fallback_client(): + """Get the active metadata fallback client based on settings. + Returns an iTunesClient or DeezerClient instance with identical interfaces.""" + source = _get_metadata_fallback_source() + if source == 'deezer': + return _get_deezer_client() + from core.itunes_client import iTunesClient + return iTunesClient() + @app.route('/api/deezer/playlist/', methods=['GET']) def get_deezer_playlist(playlist_id): """Fetch a Deezer playlist by ID or URL""" @@ -26371,13 +26384,12 @@ def _run_deezer_discovery_worker(playlist_id): # Determine which provider to use use_spotify = spotify_client and spotify_client.is_spotify_authenticated() - discovery_source = 'spotify' if use_spotify else 'itunes' + discovery_source = 'spotify' if use_spotify else _get_metadata_fallback_source() - # Initialize iTunes client if needed + # Initialize fallback client if needed itunes_client_instance = None if not use_spotify: - from core.itunes_client import iTunesClient - itunes_client_instance = iTunesClient() + itunes_client_instance = _get_metadata_fallback_client() print(f"🎵 Starting Deezer discovery for: {playlist['name']} (using {discovery_source.upper()})") @@ -26514,15 +26526,15 @@ def _run_deezer_discovery_worker(playlist_id): state['spotify_matches'] = successful_discoveries elif not use_spotify and track_result and isinstance(track_result, dict): - # iTunes: Function returns a dict with track data (includes 'confidence' key) + # Fallback: Function returns a dict with track data (includes 'confidence' key) match_confidence = track_result.pop('confidence', 0.80) match_data = track_result - match_data['source'] = 'itunes' - # Extract image URL from iTunes album images - _itunes_album = match_data.get('album', {}) - _itunes_images = _itunes_album.get('images', []) if isinstance(_itunes_album, dict) else [] - if _itunes_images and 'image_url' not in match_data: - match_data['image_url'] = _itunes_images[0].get('url', '') + match_data['source'] = discovery_source + # Extract image URL from album images + _fb_album = match_data.get('album', {}) + _fb_images = _fb_album.get('images', []) if isinstance(_fb_album, dict) else [] + if _fb_images and 'image_url' not in match_data: + match_data['image_url'] = _fb_images[0].get('url', '') result['spotify_data'] = match_data result['match_data'] = match_data result['status'] = '✅ Found' @@ -27180,13 +27192,12 @@ def _run_spotify_public_discovery_worker(url_hash): # Determine which provider to use use_spotify = spotify_client and spotify_client.is_spotify_authenticated() - discovery_source = 'spotify' if use_spotify else 'itunes' + discovery_source = 'spotify' if use_spotify else _get_metadata_fallback_source() - # Initialize iTunes client if needed + # Initialize fallback client if needed itunes_client_instance = None if not use_spotify: - from core.itunes_client import iTunesClient - itunes_client_instance = iTunesClient() + itunes_client_instance = _get_metadata_fallback_client() print(f"🎵 Starting Spotify Public discovery for: {playlist['name']} (using {discovery_source.upper()})") @@ -27334,15 +27345,15 @@ def _run_spotify_public_discovery_worker(url_hash): state['spotify_matches'] = successful_discoveries elif not use_spotify and track_result and isinstance(track_result, dict): - # iTunes: Function returns a dict with track data (includes 'confidence' key) + # Fallback: Function returns a dict with track data (includes 'confidence' key) match_confidence = track_result.pop('confidence', 0.80) match_data = track_result - match_data['source'] = 'itunes' - # Extract image URL from iTunes album images - _itunes_album = match_data.get('album', {}) - _itunes_images = _itunes_album.get('images', []) if isinstance(_itunes_album, dict) else [] - if _itunes_images and 'image_url' not in match_data: - match_data['image_url'] = _itunes_images[0].get('url', '') + match_data['source'] = discovery_source + # Extract image URL from album images + _fb_album = match_data.get('album', {}) + _fb_images = _fb_album.get('images', []) if isinstance(_fb_album, dict) else [] + if _fb_images and 'image_url' not in match_data: + match_data['image_url'] = _fb_images[0].get('url', '') result['spotify_data'] = match_data result['match_data'] = match_data result['status'] = '✅ Found' @@ -27851,11 +27862,10 @@ def _run_youtube_discovery_worker(url_hash): # Determine which provider to use (Spotify preferred, iTunes fallback) use_spotify = spotify_client and spotify_client.is_spotify_authenticated() - discovery_source = 'spotify' if use_spotify else 'itunes' + discovery_source = 'spotify' if use_spotify else _get_metadata_fallback_source() - # Get iTunes client for fallback - from core.itunes_client import iTunesClient - itunes_client = iTunesClient() + # Get fallback client + itunes_client = _get_metadata_fallback_client() print(f"🔍 Starting {discovery_source} discovery for {len(tracks)} YouTube tracks...") @@ -28168,11 +28178,10 @@ def _run_listenbrainz_discovery_worker(state_key): # Determine which provider to use (Spotify preferred, iTunes fallback) use_spotify = spotify_client and spotify_client.is_spotify_authenticated() - discovery_source = 'spotify' if use_spotify else 'itunes' + discovery_source = 'spotify' if use_spotify else _get_metadata_fallback_source() - # Get iTunes client for fallback - from core.itunes_client import iTunesClient - itunes_client = iTunesClient() + # Get fallback client + itunes_client = _get_metadata_fallback_client() print(f"🔍 Starting {discovery_source} discovery for {len(tracks)} ListenBrainz tracks...") @@ -30145,43 +30154,48 @@ def add_to_watchlist(): return jsonify({"success": False, "error": "Missing artist_id or artist_name"}), 400 database = get_database() - success = database.add_artist_to_watchlist(artist_id, artist_name, profile_id=get_current_profile_id()) + # Detect ID type and determine source + is_numeric_id = artist_id.isdigit() + fallback_source = _get_metadata_fallback_source() + source = fallback_source if is_numeric_id else 'spotify' + success = database.add_artist_to_watchlist(artist_id, artist_name, profile_id=get_current_profile_id(), source=source) if success: - # Detect ID type: iTunes IDs are purely numeric, Spotify IDs are alphanumeric - is_itunes_id = artist_id.isdigit() - + # Fetch and cache artist image immediately try: - if is_itunes_id: - # For iTunes artists, fetch image from iTunes API - # We look up 'album' entity because 'artist' entity doesn't always have artwork - # We fallback to the first album's artwork as the artist image + if is_numeric_id: + # For numeric IDs, fetch image from the configured fallback source try: - itunes_url = f"https://itunes.apple.com/lookup?id={artist_id}&entity=album&limit=5" - print(f"🔍 Fetching iTunes artist image: {itunes_url}") - resp = requests.get(itunes_url, timeout=5) - - image_url = None - if resp.status_code == 200: - data = resp.json() - results = data.get('results', []) - - # Iterate results to find one with artwork - for res in results: - if 'artworkUrl100' in res: - # Get highest res by replacing dimensions - # iTunes artwork URLs usually end in .../100x100bb.jpg - image_url = res['artworkUrl100'].replace('100x100', '600x600') - break - + if fallback_source == 'deezer': + # Deezer artists have direct image URLs + fallback = _get_metadata_fallback_client() + artist_info = fallback.get_artist(artist_id) + image_url = artist_info.get('images', [{}])[0].get('url') if artist_info and artist_info.get('images') else None + else: + # iTunes: look up album entity for artwork + itunes_url = f"https://itunes.apple.com/lookup?id={artist_id}&entity=album&limit=5" + print(f"🔍 Fetching iTunes artist image: {itunes_url}") + resp = requests.get(itunes_url, timeout=5) + + image_url = None + if resp.status_code == 200: + resp_data = resp.json() + results = resp_data.get('results', []) + + # Iterate results to find one with artwork + for res in results: + if 'artworkUrl100' in res: + image_url = res['artworkUrl100'].replace('100x100', '600x600') + break + if image_url: database.update_watchlist_artist_image(artist_id, image_url) - print(f"✅ Cached iTunes artist image for {artist_name}") + print(f"✅ Cached {fallback_source} artist image for {artist_name}") else: - print(f"⚠️ No artwork found for iTunes artist {artist_name}") - except Exception as itunes_error: - print(f"⚠️ Error fetching iTunes artwork: {itunes_error}") + print(f"⚠️ No artwork found for {fallback_source} artist {artist_name}") + except Exception as fb_error: + print(f"⚠️ Error fetching {fallback_source} artwork: {fb_error}") elif spotify_client and spotify_client.is_authenticated(): # For Spotify artists, fetch from Spotify API artist_data = spotify_client.get_artist(artist_id) @@ -30285,27 +30299,39 @@ def add_batch_to_watchlist(): if not artist_id or not artist_name: continue - # Check if already watched - if database.is_artist_in_watchlist(artist_id, profile_id=get_current_profile_id()): + # Check if already watched (by ID or name) + if database.is_artist_in_watchlist(artist_id, profile_id=get_current_profile_id(), artist_name=artist_name): skipped += 1 continue - success = database.add_artist_to_watchlist(artist_id, artist_name, profile_id=get_current_profile_id()) + is_numeric = artist_id.isdigit() + fb_source = _get_metadata_fallback_source() + src = fb_source if is_numeric else 'spotify' + success = database.add_artist_to_watchlist(artist_id, artist_name, profile_id=get_current_profile_id(), source=src) if success: added += 1 # Cache artist image try: - is_itunes_id = artist_id.isdigit() - if is_itunes_id: - itunes_url = f"https://itunes.apple.com/lookup?id={artist_id}&entity=album&limit=5" - resp = requests.get(itunes_url, timeout=5) - if resp.status_code == 200: - results = resp.json().get('results', []) - for res in results: - if 'artworkUrl100' in res: - image_url = res['artworkUrl100'].replace('100x100', '600x600') + is_numeric_id = artist_id.isdigit() + if is_numeric_id: + fb_source = _get_metadata_fallback_source() + if fb_source == 'deezer': + fb_client = _get_metadata_fallback_client() + fb_artist = fb_client.get_artist(artist_id) + if fb_artist and fb_artist.get('images'): + image_url = fb_artist['images'][0].get('url') + if image_url: database.update_watchlist_artist_image(artist_id, image_url) - break + else: + itunes_url = f"https://itunes.apple.com/lookup?id={artist_id}&entity=album&limit=5" + resp = requests.get(itunes_url, timeout=5) + if resp.status_code == 200: + results = resp.json().get('results', []) + for res in results: + if 'artworkUrl100' in res: + image_url = res['artworkUrl100'].replace('100x100', '600x600') + database.update_watchlist_artist_image(artist_id, image_url) + break elif spotify_client and spotify_client.is_authenticated(): artist_data = spotify_client.get_artist(artist_id) if artist_data and 'images' in artist_data and artist_data['images']: @@ -30368,11 +30394,14 @@ def watchlist_all_unwatched_library_artists(): continue # Check if already watched (shouldn't be since we filtered, but safety check) - if database.is_artist_in_watchlist(artist_id, profile_id=get_current_profile_id()): + if database.is_artist_in_watchlist(artist_id, profile_id=get_current_profile_id(), artist_name=artist_name): skipped_already += 1 continue - success = database.add_artist_to_watchlist(artist_id, artist_name, profile_id=get_current_profile_id()) + is_numeric = artist_id.isdigit() + fb_source = _get_metadata_fallback_source() + src = fb_source if is_numeric else 'spotify' + success = database.add_artist_to_watchlist(artist_id, artist_name, profile_id=get_current_profile_id(), source=src) if success: added += 1 # Use library thumb_url if available (no HTTP calls needed) @@ -30472,19 +30501,20 @@ def check_watchlist_status_batch(): def start_watchlist_scan(): """Start a watchlist scan for new releases""" try: - # Check if MetadataService can provide a working client (Spotify OR iTunes) + # Check if MetadataService can provide a working client (Spotify OR fallback) from core.metadata_service import MetadataService metadata_service = MetadataService() - - # Get active provider - will be either spotify or itunes + + # Get active provider - will be spotify or the configured fallback active_provider = metadata_service.get_active_provider() provider_info = metadata_service.get_provider_info() - + # Verify we have at least one working provider if not provider_info['spotify_authenticated'] and not provider_info['itunes_available']: + fallback_name = provider_info.get('fallback_source', 'iTunes').capitalize() return jsonify({ - "success": False, - "error": "No music provider available. Please authenticate Spotify or ensure iTunes is accessible." + "success": False, + "error": f"No music provider available. Please authenticate Spotify or ensure {fallback_name} is accessible." }), 400 logger.info(f"Starting watchlist scan with {active_provider} provider") @@ -31239,9 +31269,9 @@ def watchlist_artist_link_provider(artist_id): return jsonify({"success": False, "error": "No data provided"}), 400 new_provider_id = data.get('provider_id', '').strip() - provider = data.get('provider', '').strip() # 'spotify' or 'itunes' + provider = data.get('provider', '').strip() # 'spotify', 'itunes', or 'deezer' - if not new_provider_id or provider not in ('spotify', 'itunes'): + if not new_provider_id or provider not in ('spotify', 'itunes', 'deezer'): return jsonify({"success": False, "error": "Missing provider or provider_id"}), 400 conn = sqlite3.connect(str(database.database_path)) @@ -31251,8 +31281,8 @@ def watchlist_artist_link_provider(artist_id): cursor.execute(""" SELECT id, artist_name, spotify_artist_id, itunes_artist_id FROM watchlist_artists - WHERE spotify_artist_id = ? OR itunes_artist_id = ? - """, (artist_id, artist_id)) + WHERE spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ? + """, (artist_id, artist_id, artist_id)) row = cursor.fetchone() if not row: @@ -31263,7 +31293,8 @@ def watchlist_artist_link_provider(artist_id): artist_name = row[1] # Check for duplicate — another watchlist artist already has this provider ID - col = 'spotify_artist_id' if provider == 'spotify' else 'itunes_artist_id' + col_map = {'spotify': 'spotify_artist_id', 'itunes': 'itunes_artist_id', 'deezer': 'deezer_artist_id'} + col = col_map[provider] cursor.execute(f"SELECT id, artist_name FROM watchlist_artists WHERE {col} = ? AND id != ?", (new_provider_id, watchlist_row_id)) duplicate = cursor.fetchone() @@ -31271,12 +31302,8 @@ def watchlist_artist_link_provider(artist_id): conn.close() return jsonify({"success": False, "error": f"Another watchlist artist ('{duplicate[1]}') already has this {provider} ID"}), 409 - if provider == 'spotify': - cursor.execute("UPDATE watchlist_artists SET spotify_artist_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", - (new_provider_id, watchlist_row_id)) - else: - cursor.execute("UPDATE watchlist_artists SET itunes_artist_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", - (new_provider_id, watchlist_row_id)) + cursor.execute(f"UPDATE watchlist_artists SET {col} = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + (new_provider_id, watchlist_row_id)) conn.commit() conn.close() @@ -32057,11 +32084,11 @@ metadata_update_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix= def _get_active_discovery_source(): """ Determine which music source is active for discovery. - Returns 'spotify' if Spotify is authenticated, 'itunes' otherwise. + Returns 'spotify' if Spotify is authenticated, otherwise the configured fallback. """ if spotify_client and spotify_client.is_spotify_authenticated(): return 'spotify' - return 'itunes' + return _get_metadata_fallback_source() @app.route('/api/discover/hero', methods=['GET']) @@ -32074,9 +32101,8 @@ def get_discover_hero(): active_source = _get_active_discovery_source() print(f"🎵 Discover hero using source: {active_source}") - # Import iTunes client for fallback - from core.itunes_client import iTunesClient - itunes_client = iTunesClient() + # Import fallback client for non-Spotify lookups + itunes_client = _get_metadata_fallback_client() # Get top similar artists (excluding watchlist, cycled by last_featured) # Fetch more than needed since strict source filtering may drop many @@ -32098,7 +32124,12 @@ def get_discover_hero(): hero_artists = [] for artist in shuffled_watchlist[:10]: - artist_id = artist.itunes_artist_id if active_source == 'itunes' else artist.spotify_artist_id + if active_source == 'spotify': + artist_id = artist.spotify_artist_id + elif active_source == 'deezer': + artist_id = getattr(artist, 'deezer_artist_id', None) or artist.itunes_artist_id + else: + artist_id = artist.itunes_artist_id if not artist_id: continue @@ -32127,35 +32158,41 @@ def get_discover_hero(): for artist in similar_artists: if active_source == 'spotify' and artist.similar_artist_spotify_id: valid_artists.append(artist) + elif active_source == 'deezer' and getattr(artist, 'similar_artist_deezer_id', None): + valid_artists.append(artist) elif active_source == 'itunes' and artist.similar_artist_itunes_id: valid_artists.append(artist) - # FALLBACK: If no valid artists for iTunes, try to resolve iTunes IDs on-the-fly - if active_source == 'itunes' and not valid_artists: - print(f"[iTunes Fallback] No artists with iTunes IDs found, attempting on-the-fly resolution for {len(similar_artists)} artists") + # FALLBACK: If no valid artists for fallback source, try to resolve IDs on-the-fly + if active_source in ('itunes', 'deezer') and not valid_artists: + print(f"[{active_source} Fallback] No artists with {active_source} IDs found, attempting on-the-fly resolution for {len(similar_artists)} artists") resolved_count = 0 for artist in similar_artists: - if artist.similar_artist_itunes_id: + existing_id = getattr(artist, f'similar_artist_{active_source}_id', None) or (artist.similar_artist_itunes_id if active_source == 'itunes' else None) + if existing_id: valid_artists.append(artist) continue - # Try to resolve iTunes ID by name + # Try to resolve ID by name try: - itunes_results = itunes_client.search_artists(artist.similar_artist_name, limit=1) - if itunes_results and len(itunes_results) > 0: - itunes_id = itunes_results[0].id + search_results = itunes_client.search_artists(artist.similar_artist_name, limit=1) + if search_results and len(search_results) > 0: + resolved_id = search_results[0].id # Cache the resolved ID for future use - database.update_similar_artist_itunes_id(artist.id, itunes_id) - # Create a modified artist object with the resolved ID - artist.similar_artist_itunes_id = itunes_id + if active_source == 'deezer': + database.update_similar_artist_deezer_id(artist.id, resolved_id) + artist.similar_artist_deezer_id = resolved_id + else: + database.update_similar_artist_itunes_id(artist.id, resolved_id) + artist.similar_artist_itunes_id = resolved_id valid_artists.append(artist) resolved_count += 1 - print(f" [Resolved] {artist.similar_artist_name} -> iTunes ID: {itunes_id}") + print(f" [Resolved] {artist.similar_artist_name} -> {active_source} ID: {resolved_id}") except Exception as resolve_err: - print(f" [Failed] Could not resolve iTunes ID for {artist.similar_artist_name}: {resolve_err}") + print(f" [Failed] Could not resolve {active_source} ID for {artist.similar_artist_name}: {resolve_err}") # Stop after 10 successful resolutions to avoid rate limiting if len(valid_artists) >= 10: break - print(f"[iTunes Fallback] Resolved {resolved_count} artists with iTunes IDs") + print(f"[{active_source} Fallback] Resolved {resolved_count} artists with IDs") print(f"[Discover Hero] Found {len(valid_artists)} valid artists for source: {active_source}") @@ -32168,6 +32205,8 @@ def get_discover_hero(): # Use the ID for the active source, falling back to the other if needed if active_source == 'spotify': artist_id = artist.similar_artist_spotify_id or artist.similar_artist_itunes_id + elif active_source == 'deezer': + artist_id = getattr(artist, 'similar_artist_deezer_id', None) or artist.similar_artist_itunes_id or artist.similar_artist_spotify_id else: artist_id = artist.similar_artist_itunes_id or artist.similar_artist_spotify_id @@ -32202,18 +32241,21 @@ def get_discover_hero(): artist.id, artist_data.get('image_url'), artist_data.get('genres'), artist_data.get('popularity') ) - elif active_source == 'itunes' and artist.similar_artist_itunes_id: - itunes_artist = itunes_client.get_artist(artist.similar_artist_itunes_id) - if itunes_artist: - artist_data['artist_name'] = itunes_artist.get('name', artist.similar_artist_name) - artist_data['image_url'] = itunes_artist.get('images', [{}])[0].get('url') if itunes_artist.get('images') else None - artist_data['genres'] = itunes_artist.get('genres', []) - artist_data['popularity'] = itunes_artist.get('popularity', 0) - # Cache it - database.update_similar_artist_metadata( - artist.id, artist_data.get('image_url'), - artist_data.get('genres'), artist_data.get('popularity') - ) + elif active_source in ('itunes', 'deezer'): + fb_artist_id = getattr(artist, 'similar_artist_deezer_id', None) if active_source == 'deezer' else None + fb_artist_id = fb_artist_id or artist.similar_artist_itunes_id + if fb_artist_id: + fb_artist_data = itunes_client.get_artist(fb_artist_id) + if fb_artist_data: + artist_data['artist_name'] = fb_artist_data.get('name', artist.similar_artist_name) + artist_data['image_url'] = fb_artist_data.get('images', [{}])[0].get('url') if fb_artist_data.get('images') else None + artist_data['genres'] = fb_artist_data.get('genres', []) + artist_data['popularity'] = fb_artist_data.get('popularity', 0) + # Cache it + database.update_similar_artist_metadata( + artist.id, artist_data.get('image_url'), + artist_data.get('genres'), artist_data.get('popularity') + ) except Exception as img_err: print(f"Could not fetch artist image: {img_err}") @@ -32247,11 +32289,15 @@ def get_discover_similar_artists(): for artist in similar_artists: if active_source == 'spotify' and not artist.similar_artist_spotify_id: continue + if active_source == 'deezer' and not getattr(artist, 'similar_artist_deezer_id', None) and not artist.similar_artist_itunes_id: + continue if active_source == 'itunes' and not artist.similar_artist_itunes_id: continue if active_source == 'spotify': artist_id = artist.similar_artist_spotify_id + elif active_source == 'deezer': + artist_id = getattr(artist, 'similar_artist_deezer_id', None) or artist.similar_artist_itunes_id else: artist_id = artist.similar_artist_itunes_id @@ -32308,7 +32354,12 @@ def enrich_similar_artists(): cached_artists = database.get_top_similar_artists(limit=500, profile_id=get_current_profile_id()) cache_map = {} for artist in cached_artists: - ext_id = artist.similar_artist_spotify_id if source == 'spotify' else artist.similar_artist_itunes_id + if source == 'spotify': + ext_id = artist.similar_artist_spotify_id + elif source == 'deezer': + ext_id = getattr(artist, 'similar_artist_deezer_id', None) or artist.similar_artist_itunes_id + else: + ext_id = artist.similar_artist_itunes_id if ext_id and ext_id not in cache_map: cache_map[ext_id] = artist @@ -32352,23 +32403,23 @@ def enrich_similar_artists(): _detect_and_set_rate_limit(e, 'enrich_similar_artists') print(f"Error enriching Spotify batch: {e}") else: - from core.itunes_client import iTunesClient - itunes_client = iTunesClient() + fallback_client = _get_metadata_fallback_client() + fallback_source = _get_metadata_fallback_source() for aid in uncached_ids[:50]: try: - itunes_artist = itunes_client.get_artist(aid) - if itunes_artist: - img_url = itunes_artist.get('images', [{}])[0].get('url') if itunes_artist.get('images') else None - genres = itunes_artist.get('genres', [])[:3] + fb_artist = fallback_client.get_artist(aid) + if fb_artist: + img_url = fb_artist.get('images', [{}])[0].get('url') if fb_artist.get('images') else None + genres = fb_artist.get('genres', [])[:3] enriched[aid] = { - "artist_name": itunes_artist.get('name'), + "artist_name": fb_artist.get('name'), "image_url": img_url, "genres": genres, "popularity": 0 } # Cache to DB for future use database.update_similar_artist_metadata_by_external_id( - aid, 'itunes', + aid, fallback_source, image_url=img_url, genres=genres, popularity=0 ) except Exception: @@ -32533,6 +32584,8 @@ def get_discover_release_radar(): for track in discovery_tracks: if active_source == 'spotify' and track.spotify_track_id: tracks_by_id[track.spotify_track_id] = track + elif active_source == 'deezer' and getattr(track, 'deezer_track_id', None): + tracks_by_id[track.deezer_track_id] = track elif active_source == 'itunes' and track.itunes_track_id: tracks_by_id[track.itunes_track_id] = track @@ -32550,9 +32603,10 @@ def get_discover_release_radar(): track_data = None selected_tracks.append({ - "track_id": track.spotify_track_id or track.itunes_track_id, + "track_id": track.spotify_track_id or getattr(track, 'deezer_track_id', None) or track.itunes_track_id, "spotify_track_id": track.spotify_track_id, "itunes_track_id": track.itunes_track_id, + "deezer_track_id": getattr(track, 'deezer_track_id', None), "track_name": track.track_name, "artist_name": track.artist_name, "album_name": track.album_name, @@ -32597,6 +32651,8 @@ def get_discover_weekly(): for track in discovery_tracks: if active_source == 'spotify' and track.spotify_track_id: tracks_by_id[track.spotify_track_id] = track + elif active_source == 'deezer' and getattr(track, 'deezer_track_id', None): + tracks_by_id[track.deezer_track_id] = track elif active_source == 'itunes' and track.itunes_track_id: tracks_by_id[track.itunes_track_id] = track @@ -32614,9 +32670,10 @@ def get_discover_weekly(): track_data = None selected_tracks.append({ - "track_id": track.spotify_track_id or track.itunes_track_id, + "track_id": track.spotify_track_id or getattr(track, 'deezer_track_id', None) or track.itunes_track_id, "spotify_track_id": track.spotify_track_id, "itunes_track_id": track.itunes_track_id, + "deezer_track_id": getattr(track, 'deezer_track_id', None), "track_name": track.track_name, "artist_name": track.artist_name, "album_name": track.album_name, @@ -33186,14 +33243,13 @@ def search_artists_for_playlist(): logger.warning(f"Spotify artist search failed, falling back to iTunes: {e}") if not artists: - from core.itunes_client import iTunesClient - itunes = iTunesClient() - artist_objs = itunes.search_artists(query, limit=10) + fallback = _get_metadata_fallback_client() + artist_objs = fallback.search_artists(query, limit=10) for artist in artist_objs: - # iTunes artist search rarely returns images — grab from album art + # Fallback artist search may not return images — grab from album art image = artist.image_url if not image: - image = itunes._get_artist_image_from_albums(artist.id) + image = fallback._get_artist_image_from_albums(artist.id) artists.append({ 'id': artist.id, 'name': artist.name, @@ -35821,13 +35877,12 @@ def _run_beatport_discovery_worker(url_hash): # Determine which provider to use use_spotify = spotify_client and spotify_client.is_spotify_authenticated() - discovery_source = 'spotify' if use_spotify else 'itunes' + discovery_source = 'spotify' if use_spotify else _get_metadata_fallback_source() - # Initialize iTunes client if needed + # Initialize fallback client if needed itunes_client_instance = None if not use_spotify: - from core.itunes_client import iTunesClient - itunes_client_instance = iTunesClient() + itunes_client_instance = _get_metadata_fallback_client() print(f"🔍 Starting {discovery_source.upper()} discovery for {len(tracks)} Beatport tracks...") @@ -36051,7 +36106,7 @@ def _run_beatport_discovery_worker(url_hash): 'artists': formatted_artists, 'album': album_data, 'id': track_id, - 'source': 'itunes' + 'source': discovery_source } state['spotify_matches'] += 1 diff --git a/webui/index.html b/webui/index.html index 21d0f88d..e8e72cff 100644 --- a/webui/index.html +++ b/webui/index.html @@ -3626,6 +3626,21 @@ + +
+

Metadata Source

+
+ + +
+
+
When Spotify is not connected, this source provides artist, album, and track metadata. Both are free and require no API key.
+
+
+
+
+ Deezer + 0 +
Total Hits 0 @@ -5664,6 +5683,7 @@ +