diff --git a/config/settings.py b/config/settings.py index ce46db05..8b9d4d4e 100644 --- a/config/settings.py +++ b/config/settings.py @@ -86,6 +86,10 @@ class ConfigManager: 'lastfm.api_secret', 'lastfm.session_key', 'genius.access_token', + # Deezer OAuth + 'deezer.app_id', + 'deezer.app_secret', + 'deezer.access_token', # Other 'hydrabase.api_key', 'discogs.token', diff --git a/core/deezer_client.py b/core/deezer_client.py index 53076a29..f6981240 100644 --- a/core/deezer_client.py +++ b/core/deezer_client.py @@ -223,20 +223,40 @@ class DeezerClient: 'User-Agent': 'SoulSync/1.0', 'Accept': 'application/json' }) - logger.info("Deezer client initialized") + self._access_token = None + self._load_token() + logger.info("Deezer client initialized" + (" (authenticated)" if self._access_token else " (public)")) + + def _load_token(self): + """Load OAuth access token from config if available.""" + try: + from config.settings import config_manager + self._access_token = config_manager.get('deezer.access_token', None) + except Exception: + self._access_token = None + + def is_user_authenticated(self) -> bool: + """Check if we have a Deezer OAuth user token (for favorites, playlists, etc.)""" + return bool(self._access_token) 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 + """Reload configuration — refresh OAuth token from config.""" + self._load_token() 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""" + """Generic GET request to Deezer API with error handling. + Includes OAuth access_token when available for user-level endpoints.""" try: url = f"{self.BASE_URL}/{endpoint.lstrip('/')}" + if params is None: + params = {} + # Include access token for authenticated requests + if self._access_token and 'access_token' not in params: + params['access_token'] = self._access_token response = self.session.get(url, params=params, timeout=timeout) if response.status_code != 200: @@ -635,6 +655,45 @@ class DeezerClient: return artist_data.get('picture_xl') or artist_data.get('picture_big') or artist_data.get('picture_medium') return None + # ==================== User Methods (require OAuth) ==================== + + @rate_limited + def get_user_favorite_artists(self, limit: int = 200) -> list: + """Fetch user's favorite artists from Deezer. Requires OAuth access token. + Returns list of dicts with deezer_id, name, image_url.""" + if not self._access_token: + logger.debug("Deezer not user-authenticated — cannot fetch favorites") + return [] + try: + artists = [] + index = 0 + while len(artists) < limit: + data = self._api_get('user/me/artists', params={ + 'limit': min(100, limit - len(artists)), + 'index': index + }) + if not data or 'data' not in data: + break + items = data['data'] + if not items: + break + for a in items: + artists.append({ + 'deezer_id': str(a.get('id', '')), + 'name': a.get('name', ''), + 'image_url': a.get('picture_xl') or a.get('picture_big') or a.get('picture_medium', ''), + }) + if not data.get('next'): + break + index += len(items) + time.sleep(0.3) # Extra breathing room + + logger.info(f"Retrieved {len(artists)} favorite artists from Deezer") + return artists + except Exception as e: + logger.error(f"Error fetching Deezer favorite artists: {e}") + return [] + # ==================== Stub Methods (match iTunesClient interface) ==================== def get_user_playlists(self) -> List[Playlist]: diff --git a/core/lastfm_client.py b/core/lastfm_client.py index 37e995d5..7cb9c4f7 100644 --- a/core/lastfm_client.py +++ b/core/lastfm_client.py @@ -252,6 +252,83 @@ class LastFMClient: logger.debug(f"No artist found for query: {artist_name}") return None + def get_authenticated_username(self) -> Optional[str]: + """Get the username of the authenticated Last.fm user via signed user.getInfo call.""" + if not self.api_key or not self.api_secret or not self.session_key: + return None + try: + params = { + 'method': 'user.getInfo', + 'api_key': self.api_key, + 'sk': self.session_key, + 'format': 'json' + } + params['api_sig'] = self._sign_request({k: v for k, v in params.items() if k != 'format'}) + response = self.session.get(self.BASE_URL, params=params, timeout=10) + response.raise_for_status() + data = response.json() + username = data.get('user', {}).get('name') + if username: + logger.info(f"Last.fm authenticated user: {username}") + return username + except Exception as e: + logger.error(f"Error getting Last.fm username: {e}") + return None + + @rate_limited + def get_user_top_artists(self, username: str, period: str = 'overall', limit: int = 200) -> list: + """Fetch user's top artists from Last.fm. + Args: + username: Last.fm username + period: overall|7day|1month|3month|6month|12month + limit: max artists to return + Returns: + List of dicts with name, playcount, image_url + """ + if not username: + return [] + try: + artists = [] + page = 1 + per_page = min(limit, 200) + while len(artists) < limit: + data = self._make_request('user.getTopArtists', { + 'user': username, + 'period': period, + 'limit': str(per_page), + 'page': str(page) + }) + if not data: + break + items = data.get('topartists', {}).get('artist', []) + if not items: + break + for a in items: + image_url = None + images = a.get('image', []) + for img in reversed(images): # largest first + if img.get('#text'): + image_url = img['#text'] + break + artists.append({ + 'name': a.get('name', ''), + 'playcount': int(a.get('playcount', 0)), + 'image_url': image_url, + }) + if len(artists) >= limit: + break + # Check if more pages exist + total_pages = int(data.get('topartists', {}).get('@attr', {}).get('totalPages', 1)) + if page >= total_pages: + break + page += 1 + + logger.info(f"Retrieved {len(artists)} top artists from Last.fm for {username}") + return artists + except Exception as e: + logger.error(f"Error fetching Last.fm top artists: {e}") + return [] + @rate_limited def get_artist_info(self, artist_name: str) -> Optional[Dict[str, Any]]: """ diff --git a/core/spotify_client.py b/core/spotify_client.py index f38a820e..61278161 100644 --- a/core/spotify_client.py +++ b/core/spotify_client.py @@ -529,7 +529,7 @@ class SpotifyClient: client_id=config['client_id'], client_secret=config['client_secret'], redirect_uri=config.get('redirect_uri', "http://127.0.0.1:8888/callback"), - scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email", + scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email user-follow-read", cache_path='config/.spotify_cache' ) @@ -1018,6 +1018,56 @@ class SpotifyClient: logger.error(f"Error fetching playlist {playlist_id}: {e}") return None + @rate_limited + def get_followed_artists(self) -> list: + """Fetch all artists the user follows on Spotify. + Returns list of dicts with id, name, image_url, genres. + Requires user-follow-read scope — returns empty list on 403.""" + if not self.is_spotify_authenticated(): + return [] + try: + artists = [] + after = None + while True: + results = self.sp.current_user_followed_artists(limit=50, after=after) + if not results or 'artists' not in results: + break + items = results['artists'].get('items', []) + if not items: + break + for a in items: + image_url = a['images'][0]['url'] if a.get('images') else None + artists.append({ + 'spotify_id': a['id'], + 'name': a['name'], + 'image_url': image_url, + 'genres': a.get('genres', []), + }) + # Cursor-based pagination + cursors = results['artists'].get('cursors', {}) + after = cursors.get('after') + if not after: + break + # Throttle pagination + _pi = _get_min_api_interval() + with _api_call_lock: + elapsed = time.time() - _last_api_call_time + if elapsed < _pi: + time.sleep(_pi - elapsed) + globals()['_last_api_call_time'] = time.time() + from core.api_call_tracker import api_call_tracker + api_call_tracker.record_call('spotify', endpoint='get_followed_artists_page') + + logger.info(f"Retrieved {len(artists)} followed artists from Spotify") + return artists + except Exception as e: + if '403' in str(e) or 'Forbidden' in str(e): + logger.warning("Spotify user-follow-read scope not granted — re-authorize to see followed artists") + return [] + _detect_and_set_rate_limit(e, 'get_followed_artists') + logger.error(f"Error fetching followed artists: {e}") + return [] + @rate_limited def search_tracks(self, query: str, limit: int = 10) -> List[Track]: """Search for tracks - falls back to configured metadata source if Spotify not authenticated""" diff --git a/core/tidal_client.py b/core/tidal_client.py index ffea437f..6185e197 100644 --- a/core/tidal_client.py +++ b/core/tidal_client.py @@ -1371,19 +1371,132 @@ class TidalClient: if not self._ensure_valid_token(): logger.error("Not authenticated with Tidal") return None - - # Note: This would require user OAuth authentication - # For now, return basic info since we're using client credentials return { 'display_name': 'Tidal User', 'id': 'tidal_user', 'type': 'user' } - except Exception as e: logger.error(f"Error getting Tidal user info: {e}") return None + def get_favorite_artists(self, limit: int = 200) -> list: + """Fetch user's favorite artists from Tidal. + Returns list of dicts with tidal_id, name, image_url.""" + try: + if not self._ensure_valid_token(): + logger.debug("Tidal not authenticated — cannot fetch favorites") + return [] + + user_id, api_version = self._get_user_id() + if not user_id: + logger.warning("Could not get Tidal user ID for favorites") + return [] + + artists = [] + + if api_version == 'v2': + # V2 API: /v2/favorites with filter + offset = 0 + while len(artists) < limit: + try: + headers = self.session.headers.copy() + headers['accept'] = 'application/vnd.api+json' + resp = requests.get( + f"{self.base_url}/favorites", + params={ + 'countryCode': 'US', + 'filter[user.id]': user_id, + 'filter[type]': 'ARTISTS', + 'include': 'artists', + 'page[limit]': min(50, limit - len(artists)), + 'page[offset]': offset + }, + headers=headers, timeout=15 + ) + if resp.status_code != 200: + logger.debug(f"Tidal V2 favorites returned {resp.status_code}, trying V1") + break + data = resp.json() + # Parse included artists + included = data.get('included', []) + if not included: + items = data.get('data', []) + if not items: + break + # Try to extract from data items directly + for item in items: + attrs = item.get('attributes', {}) + name = attrs.get('name', '') + if name: + img = None + img_data = item.get('relationships', {}).get('image', {}).get('data', {}) + if isinstance(img_data, dict) and img_data.get('id'): + img = f"https://resources.tidal.com/images/{img_data['id'].replace('-', '/')}/750x750.jpg" + artists.append({'tidal_id': item.get('id', ''), 'name': name, 'image_url': img}) + else: + for inc in included: + if inc.get('type') == 'artists': + attrs = inc.get('attributes', {}) + img = None + img_rel = inc.get('relationships', {}).get('image', {}).get('data', {}) + if isinstance(img_rel, dict) and img_rel.get('id'): + img = f"https://resources.tidal.com/images/{img_rel['id'].replace('-', '/')}/750x750.jpg" + artists.append({ + 'tidal_id': str(inc.get('id', '')), + 'name': attrs.get('name', ''), + 'image_url': img, + }) + if not data.get('links', {}).get('next'): + break + offset += 50 + import time + time.sleep(0.5) + except Exception as e: + logger.debug(f"Tidal V2 favorites error: {e}") + break + + # Fallback to V1 API if V2 returned nothing + if not artists: + try: + offset = 0 + while len(artists) < limit: + resp = self.session.get( + f"{self.alt_base_url}/users/{user_id}/favorites/artists", + params={'countryCode': 'US', 'limit': min(50, limit - len(artists)), 'offset': offset}, + timeout=15 + ) + if resp.status_code != 200: + logger.debug(f"Tidal V1 favorites returned {resp.status_code}") + break + data = resp.json() + items = data.get('items', []) + if not items: + break + for item in items: + a = item.get('item', item) + img_id = (a.get('picture') or '').replace('-', '/') + img = f"https://resources.tidal.com/images/{img_id}/750x750.jpg" if img_id else None + artists.append({ + 'tidal_id': str(a.get('id', '')), + 'name': a.get('name', ''), + 'image_url': img, + }) + total = data.get('totalNumberOfItems', 0) + offset += len(items) + if offset >= total: + break + import time + time.sleep(0.5) + except Exception as e: + logger.debug(f"Tidal V1 favorites error: {e}") + + logger.info(f"Retrieved {len(artists)} favorite artists from Tidal") + return artists + except Exception as e: + logger.error(f"Error fetching Tidal favorite artists: {e}") + return [] + # Global instance _tidal_client = None diff --git a/database/music_database.py b/database/music_database.py index 3fa62e02..3e2e0eb8 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -1184,6 +1184,33 @@ class MusicDatabase: cursor.execute("CREATE INDEX IF NOT EXISTS idx_dab_name ON discovery_artist_blacklist (artist_name COLLATE NOCASE)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_dab_spotify ON discovery_artist_blacklist (spotify_artist_id)") + # Liked artists pool — aggregated followed/liked artists from connected services + cursor.execute(""" + CREATE TABLE IF NOT EXISTS liked_artists_pool ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + artist_name TEXT NOT NULL, + normalized_name TEXT NOT NULL, + spotify_artist_id TEXT, + itunes_artist_id TEXT, + deezer_artist_id TEXT, + discogs_artist_id TEXT, + image_url TEXT, + genres TEXT, + source_services TEXT DEFAULT '[]', + active_source_id TEXT, + active_source TEXT, + match_status TEXT DEFAULT 'pending', + on_watchlist INTEGER DEFAULT 0, + profile_id INTEGER DEFAULT 1, + last_fetched_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(profile_id, normalized_name) + ) + """) + cursor.execute("CREATE INDEX IF NOT EXISTS idx_lap_profile ON liked_artists_pool (profile_id)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_lap_status ON liked_artists_pool (profile_id, match_status)") + logger.info("Discovery tables created successfully") except Exception as e: @@ -8713,6 +8740,291 @@ class MusicDatabase: logger.error(f"Error getting discovery blacklist names: {e}") return set() + # ==================== Liked Artists Pool Methods ==================== + + @staticmethod + def _normalize_artist_name(name: str) -> str: + """Normalize artist name for deduplication. Lowercases, strips diacritics, + removes 'the ' prefix, collapses whitespace.""" + import unicodedata + if not name: + return '' + n = unicodedata.normalize('NFKD', name) + n = ''.join(c for c in n if not unicodedata.combining(c)) + n = n.lower().strip() + if n.startswith('the '): + n = n[4:] + # Handle "Artist, The" format (Last.fm) + if n.endswith(', the'): + n = n[:-5] + n = ' '.join(n.split()) # collapse whitespace + return n + + # Known placeholder/default images that should be treated as "no image" + _PLACEHOLDER_IMAGES = { + '2a96cbd8b46e442fc41c2b86b821562f', # Last.fm default star + } + + @classmethod + def _is_placeholder_image(cls, url: str) -> bool: + """Check if an image URL is a known service placeholder.""" + if not url: + return True + return any(ph in url for ph in cls._PLACEHOLDER_IMAGES) + + def upsert_liked_artist(self, artist_name: str, source_service: str, + source_id: str = None, source_id_type: str = None, + image_url: str = None, genres: list = None, + profile_id: int = 1) -> bool: + """Insert or merge a liked artist into the pool. Deduplicates by normalized name.""" + try: + import json + # Reject known placeholder images + if self._is_placeholder_image(image_url): + image_url = None + normalized = self._normalize_artist_name(artist_name) + if not normalized: + return False + + conn = self._get_connection() + cursor = conn.cursor() + + # Check if exists to merge source_services + cursor.execute( + "SELECT id, source_services FROM liked_artists_pool WHERE profile_id = ? AND normalized_name = ?", + (profile_id, normalized) + ) + existing = cursor.fetchone() + + if existing: + # Merge source into existing entry + current_sources = json.loads(existing['source_services'] or '[]') + if source_service not in current_sources: + current_sources.append(source_service) + + # Build SET clause with COALESCE for IDs and image + set_parts = [ + "source_services = ?", + "updated_at = CURRENT_TIMESTAMP", + "last_fetched_at = CURRENT_TIMESTAMP", + ] + params = [json.dumps(current_sources)] + + if source_id and source_id_type: + col = {'spotify': 'spotify_artist_id', 'itunes': 'itunes_artist_id', + 'deezer': 'deezer_artist_id', 'discogs': 'discogs_artist_id'}.get(source_id_type) + if col: + set_parts.append(f"{col} = COALESCE({col}, ?)") + params.append(source_id) + if image_url: + set_parts.append("image_url = COALESCE(image_url, ?)") + params.append(image_url) + if genres: + set_parts.append("genres = COALESCE(genres, ?)") + params.append(json.dumps(genres)) + + params.extend([profile_id, normalized]) + cursor.execute( + f"UPDATE liked_artists_pool SET {', '.join(set_parts)} WHERE profile_id = ? AND normalized_name = ?", + params + ) + else: + # New entry + sources_json = json.dumps([source_service]) + id_cols = {'spotify': 'spotify_artist_id', 'itunes': 'itunes_artist_id', + 'deezer': 'deezer_artist_id', 'discogs': 'discogs_artist_id'} + col_values = {v: None for v in id_cols.values()} + if source_id and source_id_type and source_id_type in id_cols: + col_values[id_cols[source_id_type]] = source_id + + cursor.execute(""" + INSERT INTO liked_artists_pool + (artist_name, normalized_name, spotify_artist_id, itunes_artist_id, + deezer_artist_id, discogs_artist_id, image_url, genres, + source_services, profile_id, last_fetched_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + """, ( + artist_name, normalized, col_values['spotify_artist_id'], + col_values['itunes_artist_id'], col_values['deezer_artist_id'], + col_values['discogs_artist_id'], image_url, + json.dumps(genres) if genres else None, sources_json, profile_id + )) + + conn.commit() + return True + except Exception as e: + logger.error(f"Error upserting liked artist '{artist_name}': {e}") + return False + + def get_liked_artists(self, profile_id: int = 1, limit: int = None, + random: bool = False, matched_only: bool = True, + page: int = 1, per_page: int = 50, + search: str = None, source_filter: str = None, + sort: str = 'name', + require_source_id: str = None, + require_image: bool = False) -> dict: + """Get liked artists from the pool. Returns {artists: [...], total: N}. + require_source_id: column name like 'spotify_artist_id' — only return artists with this ID set. + require_image: if True, only return artists with a non-empty image_url.""" + try: + conn = self._get_connection() + cursor = conn.cursor() + + where = ["profile_id = ?"] + params = [profile_id] + if matched_only: + where.append("match_status = 'matched'") + if require_source_id: + where.append(f"{require_source_id} IS NOT NULL AND {require_source_id} != ''") + if require_image: + where.append("image_url IS NOT NULL AND image_url != ''") + if search: + where.append("artist_name LIKE ? COLLATE NOCASE") + params.append(f"%{search}%") + if source_filter: + where.append("source_services LIKE ?") + params.append(f'%"{source_filter}"%') + + where_clause = " AND ".join(where) + + cursor.execute(f"SELECT COUNT(*) FROM liked_artists_pool WHERE {where_clause}", params) + total = cursor.fetchone()[0] + + order = "RANDOM()" if random else { + 'name': 'artist_name COLLATE NOCASE', + 'recent': 'created_at DESC', + 'source': 'source_services, artist_name COLLATE NOCASE' + }.get(sort, 'artist_name COLLATE NOCASE') + + query_limit = limit if limit else per_page + offset = (page - 1) * per_page if not limit else 0 + + cursor.execute(f""" + SELECT * FROM liked_artists_pool + WHERE {where_clause} + ORDER BY {order} + LIMIT ? OFFSET ? + """, params + [query_limit, offset]) + + import json + artists = [] + for r in cursor.fetchall(): + d = dict(r) + d['source_services'] = json.loads(d['source_services'] or '[]') + d['genres'] = json.loads(d['genres']) if d['genres'] else [] + artists.append(d) + + return {'artists': artists, 'total': total} + except Exception as e: + logger.error(f"Error getting liked artists: {e}") + return {'artists': [], 'total': 0} + + def get_liked_artists_last_fetch(self, profile_id: int = 1): + """Get the most recent fetch timestamp.""" + try: + conn = self._get_connection() + cursor = conn.cursor() + cursor.execute( + "SELECT MAX(last_fetched_at) FROM liked_artists_pool WHERE profile_id = ?", + (profile_id,) + ) + row = cursor.fetchone() + return row[0] if row and row[0] else None + except Exception: + return None + + def update_liked_artist_match(self, pool_id: int, active_source: str = None, + active_source_id: str = None, image_url: str = None, + all_ids: dict = None) -> bool: + """Mark a liked artist as matched. Stores all discovered source IDs, not just active. + all_ids: optional dict like {'spotify_artist_id': '...', 'itunes_artist_id': '...'}""" + try: + conn = self._get_connection() + cursor = conn.cursor() + set_parts = ["match_status = 'matched'", "updated_at = CURRENT_TIMESTAMP"] + params = [] + + if active_source and active_source_id: + set_parts.append("active_source = ?") + set_parts.append("active_source_id = ?") + params.extend([active_source, active_source_id]) + + # Store all discovered source IDs (COALESCE preserves existing values) + if all_ids: + for col in ('spotify_artist_id', 'itunes_artist_id', 'deezer_artist_id', 'discogs_artist_id'): + val = all_ids.get(col) + if val: + set_parts.append(f"{col} = COALESCE({col}, ?)") + params.append(str(val)) + + # Update image — replace if current is NULL or empty string + if image_url: + set_parts.append("image_url = CASE WHEN image_url IS NULL OR image_url = '' THEN ? ELSE image_url END") + params.append(image_url) + + params.append(pool_id) + cursor.execute(f"UPDATE liked_artists_pool SET {', '.join(set_parts)} WHERE id = ?", params) + conn.commit() + return cursor.rowcount > 0 + except Exception as e: + logger.error(f"Error updating liked artist match: {e}") + return False + + def sync_liked_artists_watchlist_flags(self, profile_id: int = 1) -> int: + """Batch-update on_watchlist flags by checking against watchlist_artists. + Uses case-insensitive artist_name comparison (not normalized_name) to avoid + normalization mismatches like 'The Beatles' vs 'beatles'.""" + try: + conn = self._get_connection() + cursor = conn.cursor() + # Reset all, then set matches + cursor.execute( + "UPDATE liked_artists_pool SET on_watchlist = 0 WHERE profile_id = ?", + (profile_id,) + ) + cursor.execute(""" + UPDATE liked_artists_pool SET on_watchlist = 1 + WHERE profile_id = ? AND EXISTS ( + SELECT 1 FROM watchlist_artists wa + WHERE wa.profile_id = liked_artists_pool.profile_id + AND wa.artist_name = liked_artists_pool.artist_name COLLATE NOCASE + ) + """, (profile_id,)) + conn.commit() + return cursor.rowcount + except Exception as e: + logger.error(f"Error syncing liked artists watchlist flags: {e}") + return 0 + + def get_liked_artists_pending_match(self, profile_id: int = 1, limit: int = 50) -> list: + """Get artists that haven't been matched to the active source yet.""" + try: + conn = self._get_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT * FROM liked_artists_pool + WHERE profile_id = ? AND match_status = 'pending' + ORDER BY created_at + LIMIT ? + """, (profile_id, limit)) + import json + return [dict(r) for r in cursor.fetchall()] + except Exception as e: + logger.error(f"Error getting pending liked artists: {e}") + return [] + + def clear_liked_artists(self, profile_id: int = 1) -> int: + """Clear all liked artists for a profile.""" + try: + conn = self._get_connection() + cursor = conn.cursor() + cursor.execute("DELETE FROM liked_artists_pool WHERE profile_id = ?", (profile_id,)) + conn.commit() + return cursor.rowcount + except Exception as e: + logger.error(f"Error clearing liked artists: {e}") + return 0 + # ==================== Track Download Provenance Methods ==================== def record_track_download(self, file_path: str, source_service: str, source_username: str, diff --git a/web_server.py b/web_server.py index 0331b11c..8e381065 100644 --- a/web_server.py +++ b/web_server.py @@ -6632,6 +6632,100 @@ def tidal_callback(): return f"

❌ An Error Occurred

An unexpected error occurred during the authentication process: {e}

", 500 +# --- Deezer OAuth --- + +@app.route('/auth/deezer') +def auth_deezer(): + """Initialize Deezer OAuth flow. Redirects user to Deezer authorization page.""" + try: + app_id = config_manager.get('deezer.app_id', '') + redirect_uri = config_manager.get('deezer.redirect_uri', 'http://127.0.0.1:8008/deezer/callback') + + if not app_id: + return "

Deezer App ID not configured

Go to Settings → Connections and enter your Deezer App ID first.

", 400 + + perms = 'basic_access,email,offline_access,manage_library,listening_history' + import urllib.parse + auth_url = f"https://connect.deezer.com/oauth/auth.php?app_id={app_id}&redirect_uri={urllib.parse.quote(redirect_uri)}&perms={perms}" + + host = request.host.split(':')[0] + return f""" + +

🎵 Deezer Authorization

+

Click the link below to authorize SoulSync with your Deezer account:

+

Authorize on Deezer →

+
+

If running remotely, replace 127.0.0.1 in the redirect URI with {host}

+ + """ + except Exception as e: + return f"

Error

{e}

", 500 + + +@app.route('/deezer/callback') +def deezer_callback(): + """Handle Deezer OAuth callback — exchange code for access token.""" + auth_code = request.args.get('code') + error_reason = request.args.get('error_reason', '') + + if not auth_code: + return f"

Deezer Authentication Failed

{error_reason or 'No authorization code received.'}

", 400 + + try: + app_id = config_manager.get('deezer.app_id', '') + app_secret = config_manager.get('deezer.app_secret', '') + + if not app_id or not app_secret: + return "

Missing Credentials

Deezer App ID or Secret not configured.

", 400 + + # Exchange code for token — simple GET request (Deezer's unique approach) + token_url = f"https://connect.deezer.com/oauth/access_token.php?app_id={app_id}&secret={app_secret}&code={auth_code}" + resp = requests.get(token_url, timeout=15) + + if resp.status_code != 200: + return f"

Token Exchange Failed

Deezer returned status {resp.status_code}

", 400 + + # Deezer returns: access_token=TOKEN&expires=SECONDS (URL-encoded, not JSON) + import urllib.parse + token_data = dict(urllib.parse.parse_qsl(resp.text)) + access_token = token_data.get('access_token') + + if not access_token: + # Try JSON format (some Deezer API versions) + try: + json_data = resp.json() + access_token = json_data.get('access_token') + except Exception: + pass + + if not access_token: + return f"

No Access Token

Deezer response: {resp.text[:200]}

", 400 + + # Save token to config (encrypted at rest) + config_manager.set('deezer.access_token', access_token) + + # Reload the global deezer client to pick up the token + global deezer_client + if deezer_client: + deezer_client.reload_config() + else: + from core.deezer_client import DeezerClient + deezer_client = DeezerClient() + + add_activity_item("✅", "Deezer Auth Complete", "Deezer account connected via OAuth", "Now") + logger.info("Deezer OAuth authentication successful") + + return """ + +

✅ Deezer Authentication Successful!

+

Your Deezer account is now connected. You can close this window.

+ + """ + except Exception as e: + logger.error(f"Deezer OAuth callback error: {e}") + return f"

Error

{e}

", 500 + + # --- Beatport Data API --- @app.route('/api/beatport/hero-tracks') @@ -40366,6 +40460,599 @@ def remove_discovery_artist_blacklist(blacklist_id): except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 +# ── Your Artists (Liked Artists Pool) ── + +@app.route('/api/discover/your-artists', methods=['GET']) +def get_your_artists(): + """Get liked artists for the Discover carousel (20 random matched on active source).""" + try: + database = get_database() + profile_id = get_current_profile_id() + + # Determine active source column — only show artists with THIS source's ID + active_source = 'spotify' + if spotify_client and spotify_client.is_spotify_authenticated(): + active_source = 'spotify' + else: + fb = _get_metadata_fallback_source() + if fb: + active_source = fb + active_col = {'spotify': 'spotify_artist_id', 'itunes': 'itunes_artist_id', + 'deezer': 'deezer_artist_id', 'discogs': 'discogs_artist_id'}.get(active_source, 'spotify_artist_id') + + # Check if refresh needed (>24h stale or empty) + last_fetch = database.get_liked_artists_last_fetch(profile_id) + stale = True + if last_fetch: + from datetime import datetime, timedelta + try: + if isinstance(last_fetch, str): + last_dt = datetime.fromisoformat(last_fetch.replace('Z', '+00:00')) + else: + last_dt = last_fetch + stale = (datetime.now() - last_dt.replace(tzinfo=None)) > timedelta(hours=24) + except Exception: + stale = True + + if stale: + _trigger_your_artists_refresh(profile_id) + + database.sync_liked_artists_watchlist_flags(profile_id) + + # Only return artists matched to the active source + result = database.get_liked_artists( + profile_id=profile_id, limit=20, random=True, matched_only=True, + require_source_id=active_col + ) + result['stale'] = stale + result['success'] = True + result['active_source'] = active_source + return jsonify(result) + except Exception as e: + logger.error(f"Error getting your artists: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route('/api/discover/your-artists/all', methods=['GET']) +def get_your_artists_all(): + """Get all liked artists for the View All modal (paginated).""" + try: + database = get_database() + profile_id = get_current_profile_id() + page = int(request.args.get('page', 1)) + per_page = int(request.args.get('per_page', 50)) + search = request.args.get('search', '').strip() + source_filter = request.args.get('source', '').strip() + sort = request.args.get('sort', 'name') + + # Same active source filtering as carousel + active_source = 'spotify' + if spotify_client and spotify_client.is_spotify_authenticated(): + active_source = 'spotify' + else: + fb = _get_metadata_fallback_source() + if fb: + active_source = fb + active_col = {'spotify': 'spotify_artist_id', 'itunes': 'itunes_artist_id', + 'deezer': 'deezer_artist_id', 'discogs': 'discogs_artist_id'}.get(active_source, 'spotify_artist_id') + + database.sync_liked_artists_watchlist_flags(profile_id) + result = database.get_liked_artists( + profile_id=profile_id, matched_only=True, + page=page, per_page=per_page, + search=search, source_filter=source_filter or None, + sort=sort, require_source_id=active_col + ) + result['success'] = True + result['active_source'] = active_source + return jsonify(result) + except Exception as e: + logger.error(f"Error getting all your artists: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route('/api/discover/your-artists/refresh', methods=['POST']) +def refresh_your_artists(): + """Force-trigger a fetch + match cycle for liked artists. ?clear=true wipes pool first.""" + try: + profile_id = get_current_profile_id() + if request.args.get('clear', '').lower() == 'true': + database = get_database() + cleared = database.clear_liked_artists(profile_id) + print(f"🎵 [Your Artists] Cleared {cleared} entries before refresh") + _trigger_your_artists_refresh(profile_id) + return jsonify({"success": True, "message": "Refresh started"}) + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + + +_your_artists_refresh_lock = threading.Lock() +_your_artists_refreshing = False + +def _trigger_your_artists_refresh(profile_id: int): + """Start background fetch + match if not already running.""" + global _your_artists_refreshing + if _your_artists_refreshing: + return + with _your_artists_refresh_lock: + if _your_artists_refreshing: + return + _your_artists_refreshing = True + + def _run(): + global _your_artists_refreshing + try: + _fetch_and_match_liked_artists(profile_id) + except Exception as e: + logger.error(f"Your artists refresh failed: {e}") + import traceback + traceback.print_exc() + finally: + _your_artists_refreshing = False + + threading.Thread(target=_run, daemon=True, name="YourArtistsRefresh").start() + + +def _fetch_and_match_liked_artists(profile_id: int): + """Background worker: fetch from services, deduplicate, match to active source.""" + database = get_database() + fetched = 0 + + # 1. Fetch from Spotify (followed artists) + try: + if spotify_client and spotify_client.is_spotify_authenticated(): + print("🎵 [Your Artists] Fetching followed artists from Spotify...") + artists = spotify_client.get_followed_artists() + for a in artists: + database.upsert_liked_artist( + artist_name=a['name'], source_service='spotify', + source_id=a['spotify_id'], source_id_type='spotify', + image_url=a.get('image_url'), genres=a.get('genres'), + profile_id=profile_id + ) + fetched += len(artists) + print(f"🎵 [Your Artists] Fetched {len(artists)} from Spotify") + except Exception as e: + logger.error(f"[Your Artists] Spotify fetch error: {e}") + + # 2. Fetch from Tidal (favorite artists) + try: + if tidal_client and hasattr(tidal_client, 'get_favorite_artists'): + tidal_auth = tidal_client._ensure_valid_token() if hasattr(tidal_client, '_ensure_valid_token') else False + if tidal_auth: + print("🎵 [Your Artists] Fetching favorite artists from Tidal...") + artists = tidal_client.get_favorite_artists(limit=200) + for a in artists: + database.upsert_liked_artist( + artist_name=a['name'], source_service='tidal', + image_url=a.get('image_url'), profile_id=profile_id + ) + fetched += len(artists) + print(f"🎵 [Your Artists] Fetched {len(artists)} from Tidal") + except Exception as e: + logger.error(f"[Your Artists] Tidal fetch error: {e}") + + # 3. Fetch from Last.fm (top artists) + try: + lastfm_key = config_manager.get('lastfm.api_key', '') + lastfm_secret = config_manager.get('lastfm.api_secret', '') + lastfm_session = config_manager.get('lastfm.session_key', '') + print(f"🎵 [Your Artists] Last.fm credentials: key={'yes' if lastfm_key else 'NO'}, secret={'yes' if lastfm_secret else 'NO'}, session={'yes' if lastfm_session else 'NO'}") + if lastfm_key and lastfm_secret and lastfm_session: + from core.lastfm_client import LastFMClient + lfm = LastFMClient(api_key=lastfm_key, api_secret=lastfm_secret, session_key=lastfm_session) + username = lfm.get_authenticated_username() + print(f"🎵 [Your Artists] Last.fm username resolved: {username or 'NONE'}") + if username: + print(f"🎵 [Your Artists] Fetching top artists from Last.fm ({username})...") + artists = lfm.get_user_top_artists(username, period='overall', limit=200) + for a in artists: + database.upsert_liked_artist( + artist_name=a['name'], source_service='lastfm', + image_url=a.get('image_url'), profile_id=profile_id + ) + fetched += len(artists) + print(f"🎵 [Your Artists] Fetched {len(artists)} from Last.fm") + except Exception as e: + logger.error(f"[Your Artists] Last.fm fetch error: {e}") + + # 4. Fetch from Deezer (favorite artists — requires OAuth) + try: + deezer_cl = _get_deezer_client() + if deezer_cl and hasattr(deezer_cl, 'is_user_authenticated') and deezer_cl.is_user_authenticated(): + print("🎵 [Your Artists] Fetching favorite artists from Deezer...") + artists = deezer_cl.get_user_favorite_artists(limit=200) + for a in artists: + database.upsert_liked_artist( + artist_name=a['name'], source_service='deezer', + source_id=a.get('deezer_id'), source_id_type='deezer', + image_url=a.get('image_url'), profile_id=profile_id + ) + fetched += len(artists) + print(f"🎵 [Your Artists] Fetched {len(artists)} from Deezer") + except Exception as e: + logger.error(f"[Your Artists] Deezer fetch error: {e}") + + print(f"🎵 [Your Artists] Total fetched: {fetched}") + + # 5. Match pending artists to active source + _match_liked_artists_to_all_sources(database, profile_id) + + +def _match_liked_artists_to_all_sources(database, profile_id: int): + """Match pending liked artists to ALL metadata sources (Spotify, iTunes, Deezer, Discogs). + Uses the same matching pattern as the watchlist scanner: DB-first, then API search + with fuzzy name matching. Stores all resolved IDs so source switching works instantly.""" + pending = database.get_liked_artists_pending_match(profile_id, limit=200) + if not pending: + return + + # Source → column mapping + source_cols = { + 'spotify': 'spotify_artist_id', + 'itunes': 'itunes_artist_id', + 'deezer': 'deezer_artist_id', + 'discogs': 'discogs_artist_id', + } + id_cols = list(source_cols.values()) + + # Reject known placeholder images and local server paths + _placeholder_hashes = {'2a96cbd8b46e442fc41c2b86b821562f'} + def _valid_image(url): + if not url or not url.strip(): + return None + if any(ph in url for ph in _placeholder_hashes): + return None + # Reject local media server paths (Plex/Jellyfin) — not loadable in browser + if url.startswith('/') or url.startswith('\\'): + return None + if not url.startswith('http'): + return None + return url + + # Build search clients for each source + from core.deezer_client import DeezerClient + search_clients = {} + if spotify_client and spotify_client.is_spotify_authenticated(): + search_clients['spotify'] = spotify_client + try: + from core.itunes_client import iTunesClient + search_clients['itunes'] = iTunesClient() + except Exception: + pass + try: + search_clients['deezer'] = DeezerClient() + except Exception: + pass + try: + from core.discogs_client import DiscogsClient + dc = DiscogsClient() + # Only use Discogs if token is configured + from config.settings import config_manager as _cm + if _cm.get('discogs.token', ''): + search_clients['discogs'] = dc + except Exception: + pass + + # Reuse watchlist scanner's fuzzy matching logic + from core.watchlist_scanner import WatchlistScanner + _normalize = WatchlistScanner._normalize_artist_name + + def _best_match(results, artist_name): + """Pick best match from search results using name similarity (same as watchlist scanner).""" + if not results: + return None + # Exact normalized match + for r in results: + if _normalize(r.name) == _normalize(artist_name): + return r + # Fuzzy scoring + best = None + best_sim = 0 + for r in results: + # Simple normalized comparison + n1 = _normalize(artist_name) + n2 = _normalize(r.name) + if n1 == n2: + return r + # Levenshtein-style similarity + max_len = max(len(n1), len(n2)) + if max_len == 0: + continue + distance = sum(1 for a, b in zip(n1, n2) if a != b) + abs(len(n1) - len(n2)) + sim = (max_len - distance) / max_len + if sim > best_sim: + best_sim = sim + best = r + if best and best_sim >= 0.85: + return best + return None + + api_calls = 0 + matched = 0 + + for entry in pending: + name = entry['artist_name'] + pool_id = entry['id'] + harvested_ids = {} + best_image = None + + # Pre-load existing IDs from the entry itself + for col in id_cols: + if entry.get(col): + harvested_ids[col] = entry[col] + + # --- DB STRATEGIES (free, no API calls) --- + + # 1. Library artists table + try: + conn = database._get_connection() + cursor = conn.cursor() + cursor.execute("SELECT * FROM artists WHERE name = ? COLLATE NOCASE LIMIT 1", (name,)) + row = cursor.fetchone() + if row: + r = dict(row) + for col in id_cols: + if r.get(col) and col not in harvested_ids: + harvested_ids[col] = str(r[col]) + if _valid_image(r.get('thumb_url')): + best_image = r['thumb_url'] + except Exception: + pass + + # 2. Watchlist artists + try: + conn = database._get_connection() + cursor = conn.cursor() + cursor.execute( + "SELECT * FROM watchlist_artists WHERE artist_name = ? COLLATE NOCASE AND profile_id = ? LIMIT 1", + (name, profile_id) + ) + row = cursor.fetchone() + if row: + wl = dict(row) + for col in id_cols: + if wl.get(col) and col not in harvested_ids: + harvested_ids[col] = str(wl[col]) + if _valid_image(wl.get('image_url')) and not best_image: + best_image = wl['image_url'] + except Exception: + pass + + # 3. Metadata cache (all sources) + try: + conn = database._get_connection() + cursor = conn.cursor() + cursor.execute( + "SELECT entity_id, source, image_url FROM metadata_cache_entities WHERE entity_type = 'artist' AND name = ? COLLATE NOCASE", + (name,) + ) + for row in cursor.fetchall(): + col = source_cols.get(row['source']) + if col and col not in harvested_ids: + harvested_ids[col] = row['entity_id'] + if _valid_image(row['image_url']) and not best_image: + best_image = row['image_url'] + except Exception: + pass + + # --- API STRATEGIES (search each missing source) --- + # Same pattern as watchlist scanner's _backfill_missing_ids + for source, col in source_cols.items(): + if col in harvested_ids: + continue # Already have this source's ID + client = search_clients.get(source) + if not client: + continue + if api_calls >= 200: # Hard cap per refresh cycle + break + try: + results = client.search_artists(name, limit=5) + best = _best_match(results, name) + if best: + harvested_ids[col] = best.id + if hasattr(best, 'image_url') and _valid_image(best.image_url) and not best_image: + best_image = best.image_url + api_calls += 1 + time.sleep(0.4) # Rate limit breathing room + except Exception as e: + logger.debug(f"[Your Artists] {source} search failed for '{name}': {e}") + api_calls += 1 + + # Save all harvested IDs + if harvested_ids: + # Determine best active source/ID — prefer Spotify, then iTunes, Deezer, Discogs + resolved_source = None + resolved_id = None + for src in ('spotify', 'itunes', 'deezer', 'discogs'): + col = source_cols[src] + if col in harvested_ids: + resolved_source = src + resolved_id = harvested_ids[col] + break + + database.update_liked_artist_match( + pool_id, active_source=resolved_source, active_source_id=resolved_id, + image_url=best_image, all_ids=harvested_ids + ) + matched += 1 + + database.sync_liked_artists_watchlist_flags(profile_id) + print(f"🎵 [Your Artists] Matched {matched}/{len(pending)} artists to {len(search_clients)} sources ({api_calls} API calls)") + + # Image backfill: fetch images for matched artists that have IDs but no image + _backfill_liked_artist_images(database, profile_id, search_clients) + + +def _backfill_liked_artist_images(database, profile_id: int, search_clients: dict): + """Fetch images for matched artists missing artwork using their stored source IDs.""" + try: + conn = database._get_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT id, artist_name, spotify_artist_id, itunes_artist_id, deezer_artist_id + FROM liked_artists_pool + WHERE profile_id = ? AND match_status = 'matched' + AND (image_url IS NULL OR image_url = '' + OR image_url LIKE '%2a96cbd8b46e442fc41c2b86b821562f%' + OR image_url NOT LIKE 'http%') + LIMIT 100 + """, (profile_id,)) + rows = cursor.fetchall() + if not rows: + return + + print(f"🖼️ [Your Artists] Backfilling images for {len(rows)} artists...") + filled = 0 + + for row in rows: + r = dict(row) + image_url = None + + # Try Spotify artist lookup (has best images) + if r.get('spotify_artist_id') and 'spotify' in search_clients: + try: + sp = search_clients['spotify'] + if hasattr(sp, 'sp') and sp.sp: + artist_data = sp.sp.artist(r['spotify_artist_id']) + if artist_data and artist_data.get('images'): + image_url = artist_data['images'][0]['url'] + except Exception: + pass + + # Try Deezer (direct image URL from ID) + if not image_url and r.get('deezer_artist_id'): + image_url = f"https://api.deezer.com/artist/{r['deezer_artist_id']}/image?size=big" + + if image_url: + try: + cursor2 = conn.cursor() + cursor2.execute( + "UPDATE liked_artists_pool SET image_url = ? WHERE id = ?", + (image_url, r['id']) + ) + filled += 1 + except Exception: + pass + time.sleep(0.3) + + conn.commit() + if filled: + print(f"🖼️ [Your Artists] Backfilled {filled}/{len(rows)} artist images") + except Exception as e: + logger.debug(f"[Your Artists] Image backfill error: {e}") + + +@app.route('/api/discover/your-artists/info/', methods=['GET']) +def get_your_artist_info(artist_id): + """Get artist info for the Your Artists info modal. Checks library, cache, then API.""" + try: + artist_name = request.args.get('name', '') + result = {'name': artist_name, 'success': True} + + # 1. Try library DB (has enrichment data) + try: + database = get_database() + conn = database._get_connection() + cursor = conn.cursor() + # Check by various ID columns + cursor.execute(""" + SELECT * FROM artists WHERE id = ? OR spotify_artist_id = ? OR itunes_artist_id = ? + OR deezer_id = ? OR discogs_id = ? LIMIT 1 + """, (artist_id, artist_id, artist_id, artist_id, artist_id)) + row = cursor.fetchone() + if row: + r = dict(row) + result.update({ + 'name': r.get('name', artist_name), + 'genres': json.loads(r['genres']) if r.get('genres') else [], + 'summary': r.get('summary', ''), + 'image_url': r.get('thumb_url', ''), + 'spotify_artist_id': r.get('spotify_artist_id'), + 'musicbrainz_id': r.get('musicbrainz_id'), + 'deezer_id': r.get('deezer_id'), + 'itunes_artist_id': r.get('itunes_artist_id'), + 'discogs_id': r.get('discogs_id'), + 'lastfm_url': r.get('lastfm_url'), + 'tidal_id': r.get('tidal_id'), + 'lastfm_listeners': r.get('lastfm_listeners', 0), + 'lastfm_playcount': r.get('lastfm_playcount', 0), + }) + return jsonify(result) + except Exception: + pass + + # 2. Try metadata cache + try: + conn = database._get_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT raw_json, image_url FROM metadata_cache_entities + WHERE entity_type = 'artist' AND entity_id = ? LIMIT 1 + """, (artist_id,)) + row = cursor.fetchone() + if row and row['raw_json']: + cached = json.loads(row['raw_json']) + result.update({ + 'name': cached.get('name', artist_name), + 'genres': cached.get('genres', []), + 'image_url': row['image_url'] or cached.get('image_url', ''), + 'popularity': cached.get('popularity', 0), + 'followers': cached.get('followers', {}).get('total', 0) if isinstance(cached.get('followers'), dict) else cached.get('followers', 0), + }) + return jsonify(result) + except Exception: + pass + + # 3. Try Spotify API directly (genres, image, followers) + try: + if spotify_client and spotify_client.is_spotify_authenticated() and not artist_id.isdigit(): + artist_data = spotify_client.sp.artist(artist_id) + if artist_data: + result.update({ + 'name': artist_data.get('name', artist_name), + 'genres': artist_data.get('genres', []), + 'image_url': artist_data['images'][0]['url'] if artist_data.get('images') else '', + 'spotify_artist_id': artist_data.get('id'), + 'popularity': artist_data.get('popularity', 0), + 'followers': artist_data.get('followers', {}).get('total', 0), + }) + except Exception as e: + logger.debug(f"Spotify artist lookup failed for {artist_id}: {e}") + + # 4. Last.fm: bio, listeners, playcount (always try — has the best artist bios) + try: + _lfm_name = result.get('name') or artist_name + if _lfm_name and lastfm_worker and lastfm_worker.client: + lfm_info = lastfm_worker.client.get_artist_info(_lfm_name) + if lfm_info: + bio = lfm_info.get('bio', {}) + if isinstance(bio, dict): + summary = bio.get('summary', '') + else: + summary = str(bio) if bio else '' + if summary and not result.get('summary'): + result['summary'] = summary + stats = lfm_info.get('stats', {}) + if stats: + result['lastfm_listeners'] = int(stats.get('listeners', 0)) + result['lastfm_playcount'] = int(stats.get('playcount', 0)) + if not result.get('genres'): + tags = lfm_info.get('tags', {}).get('tag', []) + if tags: + result['genres'] = [t.get('name', '') for t in tags[:5] if isinstance(t, dict)] + lfm_url = lfm_info.get('url') + if lfm_url: + result['lastfm_url'] = lfm_url + except Exception as e: + logger.debug(f"Last.fm artist info failed for {artist_name}: {e}") + + # 5. Return combined info + return jsonify(result) + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + + @app.route('/api/discover/build-playlist/search-artists', methods=['GET']) def search_artists_for_playlist(): """Search for artists to use as seeds for custom playlist building""" diff --git a/webui/index.html b/webui/index.html index 34094119..7adf46b6 100644 --- a/webui/index.html +++ b/webui/index.html @@ -3063,6 +3063,28 @@ + + +