From f2e24a36df0b4ab2cbf73d6ea0a2cce888064df0 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Mon, 30 Mar 2026 11:07:48 -0700 Subject: [PATCH] Fix enrichment overwriting manual match status (#221) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user manually matched an artist to a service ID then triggered enrichment, the worker re-searched by name, failed to find a match, and overwrote the status back to not_found — despite the ID being valid. Now both Genius and AudioDB workers check for existing service IDs before searching by name. If an ID exists (from manual match), the worker uses it for a direct API lookup to enrich metadata while preserving the matched status. Added AudioDB lookup-by-ID client methods for artist, album, and track. --- core/audiodb_client.py | 57 +++++++++++++++++++++++++++++++++++ core/audiodb_worker.py | 52 +++++++++++++++++++++++++++++++- core/genius_worker.py | 67 ++++++++++++++++++++++++++++++++++++++++-- web_server.py | 11 +++++++ webui/static/helper.js | 1 + 5 files changed, 185 insertions(+), 3 deletions(-) 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' },