diff --git a/core/audiodb_client.py b/core/audiodb_client.py index 7e31fa6c..dec5322e 100644 --- a/core/audiodb_client.py +++ b/core/audiodb_client.py @@ -152,3 +152,60 @@ class AudioDBClient: except Exception as e: logger.error(f"Error searching for track '{artist_name} - {track_title}': {e}") return None + + @rate_limited + def lookup_artist_by_id(self, artist_id: str) -> Optional[Dict[str, Any]]: + """Lookup an artist directly by AudioDB ID.""" + try: + response = self.session.get( + f"{self.BASE_URL}/artist.php", + params={'i': artist_id}, + timeout=10 + ) + response.raise_for_status() + data = response.json() + artists = data.get('artists') + if artists and len(artists) > 0: + return artists[0] + return None + except Exception as e: + logger.error(f"Error looking up artist by ID {artist_id}: {e}") + return None + + @rate_limited + def lookup_album_by_id(self, album_id: str) -> Optional[Dict[str, Any]]: + """Lookup an album directly by AudioDB ID.""" + try: + response = self.session.get( + f"{self.BASE_URL}/album.php", + params={'m': album_id}, + timeout=10 + ) + response.raise_for_status() + data = response.json() + albums = data.get('album') + if albums and len(albums) > 0: + return albums[0] + return None + except Exception as e: + logger.error(f"Error looking up album by ID {album_id}: {e}") + return None + + @rate_limited + def lookup_track_by_id(self, track_id: str) -> Optional[Dict[str, Any]]: + """Lookup a track directly by AudioDB ID.""" + try: + response = self.session.get( + f"{self.BASE_URL}/track.php", + params={'m': track_id}, + timeout=10 + ) + response.raise_for_status() + data = response.json() + tracks = data.get('track') + if tracks and len(tracks) > 0: + return tracks[0] + return None + except Exception as e: + logger.error(f"Error looking up track by ID {track_id}: {e}") + return None diff --git a/core/audiodb_worker.py b/core/audiodb_worker.py index 0b9b0f85..bd204c3f 100644 --- a/core/audiodb_worker.py +++ b/core/audiodb_worker.py @@ -315,8 +315,29 @@ class AudioDBWorker: logger.debug(f"Name similarity: '{query_name}' vs '{result_name}' = {similarity:.2f}") return similarity >= self.name_similarity_threshold + def _get_existing_id(self, entity_type: str, entity_id: int) -> Optional[str]: + """Check if an entity already has an audiodb_id (e.g. from manual match).""" + table_map = {'artist': 'artists', 'album': 'albums', 'track': 'tracks'} + table = table_map.get(entity_type) + if not table: + return None + conn = None + try: + conn = self.db._get_connection() + cursor = conn.cursor() + cursor.execute(f"SELECT audiodb_id FROM {table} WHERE id = ?", (entity_id,)) + row = cursor.fetchone() + return row[0] if row and row[0] else None + except Exception: + return None + finally: + if conn: + conn.close() + def _process_item(self, item: Dict[str, Any]): - """Process a single item (artist, album, or track)""" + """Process a single item (artist, album, or track). + If the entity already has an audiodb_id (e.g. from manual match), + uses it for direct lookup instead of searching by name.""" try: item_type = item['type'] item_id = item['id'] @@ -324,6 +345,35 @@ class AudioDBWorker: logger.debug(f"Processing {item_type} #{item_id}: {item_name}") + # Check for existing ID (manual match) — use direct lookup instead of name search + existing_id = self._get_existing_id(item_type, item_id) + if existing_id: + lookup_methods = { + 'artist': self.client.lookup_artist_by_id, + 'album': self.client.lookup_album_by_id, + 'track': self.client.lookup_track_by_id, + } + update_methods = { + 'artist': lambda r: self._update_artist(item_id, r), + 'album': lambda r: (self._verify_artist_id(item, r), self._update_album(item_id, r)), + 'track': lambda r: (self._verify_artist_id(item, r), self._update_track(item_id, r)), + } + lookup = lookup_methods.get(item_type) + update = update_methods.get(item_type) + if lookup and update: + try: + result = lookup(existing_id) + if result: + update(result) + self.stats['matched'] += 1 + logger.info(f"Enriched {item_type} '{item_name}' from existing AudioDB ID: {existing_id}") + return + except Exception as e: + logger.warning(f"Direct lookup failed for existing AudioDB ID {existing_id}: {e}") + # Direct lookup failed — don't overwrite manual match + logger.debug(f"Preserving manual match for {item_type} '{item_name}' (AudioDB ID: {existing_id})") + return + if item_type == 'artist': result = self.client.search_artist(item_name) if result: diff --git a/core/genius_worker.py b/core/genius_worker.py index 65d3b022..f3468b71 100644 --- a/core/genius_worker.py +++ b/core/genius_worker.py @@ -276,8 +276,46 @@ class GeniusWorker: except Exception as e2: logger.error(f"Error updating item status: {e2}") + def _get_existing_id(self, entity_type: str, entity_id: int) -> Optional[str]: + """Check if an entity already has a genius_id (e.g. from manual match).""" + table_map = {'artist': 'artists', 'album': 'albums', 'track': 'tracks'} + table = table_map.get(entity_type) + if not table: + return None + conn = None + try: + conn = self.db._get_connection() + cursor = conn.cursor() + cursor.execute(f"SELECT genius_id FROM {table} WHERE id = ?", (entity_id,)) + row = cursor.fetchone() + return row[0] if row and row[0] else None + except Exception: + return None + finally: + if conn: + conn.close() + def _process_artist(self, artist_id: int, artist_name: str): - """Process an artist: search Genius, get full artist details""" + """Process an artist: search Genius, get full artist details. + If the artist already has a genius_id (e.g. from manual match), + uses it for direct lookup instead of searching by name.""" + + # Check for existing ID (manual match) — use direct lookup instead of name search + existing_id = self._get_existing_id('artist', artist_id) + if existing_id: + try: + full_artist = self.client.get_artist(int(existing_id)) + if full_artist: + self._update_artist(artist_id, full_artist, full_artist) + self.stats['matched'] += 1 + logger.info(f"Enriched artist '{artist_name}' from existing Genius ID: {existing_id}") + return + except Exception as e: + logger.warning(f"Direct lookup failed for existing Genius ID {existing_id}: {e}") + # Direct lookup failed — don't overwrite manual match, just return + logger.debug(f"Preserving manual match for artist '{artist_name}' (Genius ID: {existing_id})") + return + result = self.client.search_artist(artist_name) if result: result_name = result.get('name', '') @@ -310,7 +348,32 @@ class GeniusWorker: logger.debug(f"No match for artist '{artist_name}'") def _process_track(self, track_id: int, track_name: str, artist_name: str): - """Process a track: search Genius, get full song details + lyrics""" + """Process a track: search Genius, get full song details + lyrics. + If the track already has a genius_id (e.g. from manual match), + uses it for direct lookup instead of searching by name.""" + + # Check for existing ID (manual match) — use direct lookup instead of name search + existing_id = self._get_existing_id('track', track_id) + if existing_id: + try: + full_song = self.client.get_song(int(existing_id)) + if full_song: + lyrics = None + song_url = full_song.get('url') + if song_url: + try: + lyrics = self.client.get_lyrics(song_url) + except Exception: + pass + self._update_track(track_id, full_song, full_song, lyrics) + self.stats['matched'] += 1 + logger.info(f"Enriched track '{track_name}' from existing Genius ID: {existing_id}") + return + except Exception as e: + logger.warning(f"Direct lookup failed for existing Genius ID {existing_id}: {e}") + logger.debug(f"Preserving manual match for track '{track_name}' (Genius ID: {existing_id})") + return + result = self.client.search_song(artist_name, track_name) if result: result_title = result.get('title', '') diff --git a/web_server.py b/web_server.py index 1d621f7b..a2fa75c3 100644 --- a/web_server.py +++ b/web_server.py @@ -19136,6 +19136,17 @@ def get_version_info(): "title": "What's New in SoulSync", "subtitle": f"Version {SOULSYNC_VERSION} — Latest Changes", "sections": [ + { + "title": "🔧 Fix Enrichment Overwriting Manual Matches (#221)", + "description": "Enriching an entity that was manually matched no longer reverts the status to not_found", + "features": [ + "• Genius and AudioDB workers now check for existing service IDs before searching by name", + "• Manual matches are used for direct API lookup instead of re-searching by name", + "• If the direct lookup succeeds, metadata is enriched and match status is preserved", + "• If the direct lookup fails, the manual match status is preserved (not overwritten to not_found)", + "• Added AudioDB lookup-by-ID methods for artist, album, and track" + ] + }, { "title": "🔧 Fix Spotify OAuth ERR_EMPTY_RESPONSE in Docker (#220)", "description": "OAuth callback server hardened for Docker/SSH tunnel setups", diff --git a/webui/static/helper.js b/webui/static/helper.js index 649dd96d..307b1af4 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3403,6 +3403,7 @@ function closeHelperSearch() { const WHATS_NEW = { '2.1': [ // Newest features first + { title: 'Fix Enrichment Breaking Manual Matches', desc: 'Enriching a manually matched artist no longer reverts status to not_found — uses stored ID for direct lookup' }, { title: 'Fix Spotify OAuth Empty Response', desc: 'OAuth callback server now always sends a response in Docker — added health check and proper logging' }, { title: 'All Services on Dashboard', desc: 'Dashboard shows all enrichment services as live-status chips — click unconfigured ones to jump to Settings. Spotify card no longer shows "Apple Music"', page: 'dashboard' }, { title: 'Qobuz on Connections Tab', desc: 'Qobuz credentials now on Settings → Connections for metadata enrichment without needing it as download source' },