diff --git a/core/lastfm_worker.py b/core/lastfm_worker.py index f876c380..2509f4aa 100644 --- a/core/lastfm_worker.py +++ b/core/lastfm_worker.py @@ -307,7 +307,12 @@ class LastFMWorker: 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 lastfm_id (e.g. from manual match).""" + """Check if an entity already has a lastfm_url (e.g. from manual match). + + The Last.fm schema uses `lastfm_url` (not `lastfm_id`) on artists, albums, + and tracks. This helper always returned None before because it queried a + non-existent column and silently caught the exception. + """ table_map = {'artist': 'artists', 'album': 'albums', 'track': 'tracks'} table = table_map.get(entity_type) if not table: @@ -316,7 +321,7 @@ class LastFMWorker: try: conn = self.db._get_connection() cursor = conn.cursor() - cursor.execute(f"SELECT lastfm_id FROM {table} WHERE id = ?", (entity_id,)) + cursor.execute(f"SELECT lastfm_url FROM {table} WHERE id = ?", (entity_id,)) row = cursor.fetchone() return row[0] if row and row[0] else None except Exception: @@ -329,7 +334,10 @@ class LastFMWorker: """Process an artist: get full info from Last.fm""" existing_id = self._get_existing_id('artist', artist_id) if existing_id: - logger.debug(f"Preserving existing Last.fm ID for artist '{artist_name}': {existing_id}") + # Row already has a Last.fm URL but status is NULL (legacy/manual match). + # Mark as matched so the worker does not re-select it forever. + logger.debug(f"Preserving existing Last.fm URL for artist '{artist_name}': {existing_id}") + self._mark_status('artist', artist_id, 'matched') return # Use get_artist_info for detailed data (includes stats, bio, tags, similar) @@ -353,7 +361,8 @@ class LastFMWorker: """Process an album: get full info from Last.fm""" existing_id = self._get_existing_id('album', album_id) if existing_id: - logger.debug(f"Preserving existing Last.fm ID for album '{album_name}': {existing_id}") + logger.debug(f"Preserving existing Last.fm URL for album '{album_name}': {existing_id}") + self._mark_status('album', album_id, 'matched') return result = self.client.get_album_info(artist_name, album_name) @@ -376,7 +385,8 @@ class LastFMWorker: """Process a track: get full info from Last.fm""" existing_id = self._get_existing_id('track', track_id) if existing_id: - logger.debug(f"Preserving existing Last.fm ID for track '{track_name}': {existing_id}") + logger.debug(f"Preserving existing Last.fm URL for track '{track_name}': {existing_id}") + self._mark_status('track', track_id, 'matched') return result = self.client.get_track_info(artist_name, track_name) diff --git a/core/musicbrainz_worker.py b/core/musicbrainz_worker.py index 79496d2b..08e02248 100644 --- a/core/musicbrainz_worker.py +++ b/core/musicbrainz_worker.py @@ -254,16 +254,27 @@ class MusicBrainzWorker: conn.close() def _get_existing_id(self, entity_type: str, entity_id: int) -> Optional[str]: - """Check if an entity already has a musicbrainz_id (e.g. from manual match).""" - table_map = {'artist': 'artists', 'album': 'albums', 'track': 'tracks'} - table = table_map.get(entity_type) - if not table: + """Check if an entity already has a MusicBrainz ID (e.g. from manual match). + + MusicBrainz ID columns differ per entity type: artists use `musicbrainz_id`, + albums use `musicbrainz_release_id`, and tracks use `musicbrainz_recording_id`. + Before this fix, all three were queried as `musicbrainz_id`, so the + existing-ID check silently failed for albums and tracks. + """ + table_config = { + 'artist': ('artists', 'musicbrainz_id'), + 'album': ('albums', 'musicbrainz_release_id'), + 'track': ('tracks', 'musicbrainz_recording_id'), + } + cfg = table_config.get(entity_type) + if not cfg: return None + table, column = cfg conn = None try: conn = self.db._get_connection() cursor = conn.cursor() - cursor.execute(f"SELECT musicbrainz_id FROM {table} WHERE id = ?", (entity_id,)) + cursor.execute(f"SELECT {column} FROM {table} WHERE id = ?", (entity_id,)) row = cursor.fetchone() return row[0] if row and row[0] else None except Exception: @@ -285,6 +296,17 @@ class MusicBrainzWorker: existing_id = self._get_existing_id(item_type, item_id) if existing_id: logger.debug(f"Preserving existing MusicBrainz ID for {item_type} '{item_name}': {existing_id}") + # Mark as matched so this row is not re-selected forever when + # match_status is NULL but the MBID is already populated. + try: + if item_type == 'artist': + self.mb_service.update_artist_mbid(item_id, existing_id, 'matched') + elif item_type == 'album': + self.mb_service.update_album_mbid(item_id, existing_id, 'matched') + elif item_type == 'track': + self.mb_service.update_track_mbid(item_id, existing_id, 'matched') + except Exception as mark_err: + logger.error(f"Error marking {item_type} #{item_id} matched: {mark_err}") return if item_type == 'artist': diff --git a/core/qobuz_worker.py b/core/qobuz_worker.py index 15f7da98..5e85c508 100644 --- a/core/qobuz_worker.py +++ b/core/qobuz_worker.py @@ -388,6 +388,8 @@ class QobuzWorker: existing_id = self._get_existing_id('artist', artist_id) if existing_id: logger.debug(f"Preserving existing Qobuz ID for artist '{artist_name}': {existing_id}") + # Mark as matched so this row is not re-selected on every loop. + self._mark_status('artist', artist_id, 'matched') return result = self.client.search_artist(artist_name) @@ -429,6 +431,7 @@ class QobuzWorker: existing_id = self._get_existing_id('album', album_id) if existing_id: logger.debug(f"Preserving existing Qobuz ID for album '{album_name}': {existing_id}") + self._mark_status('album', album_id, 'matched') return result = self.client.search_album(artist_name, album_name) @@ -484,6 +487,7 @@ class QobuzWorker: existing_id = self._get_existing_id('track', track_id) if existing_id: logger.debug(f"Preserving existing Qobuz ID for track '{track_name}': {existing_id}") + self._mark_status('track', track_id, 'matched') return result = self.client.search_track(artist_name, track_name) diff --git a/core/tidal_worker.py b/core/tidal_worker.py index 097227fc..fa578323 100644 --- a/core/tidal_worker.py +++ b/core/tidal_worker.py @@ -401,6 +401,8 @@ class TidalWorker: existing_id = self._get_existing_id('artist', artist_id) if existing_id: logger.debug(f"Preserving existing Tidal ID for artist '{artist_name}': {existing_id}") + # Mark as matched so this row is not re-selected on every loop. + self._mark_status('artist', artist_id, 'matched') return result = self.client.search_artist(artist_name) @@ -438,6 +440,7 @@ class TidalWorker: existing_id = self._get_existing_id('album', album_id) if existing_id: logger.debug(f"Preserving existing Tidal ID for album '{album_name}': {existing_id}") + self._mark_status('album', album_id, 'matched') return result = self.client.search_album(artist_name, album_name) @@ -486,6 +489,7 @@ class TidalWorker: existing_id = self._get_existing_id('track', track_id) if existing_id: logger.debug(f"Preserving existing Tidal ID for track '{track_name}': {existing_id}") + self._mark_status('track', track_id, 'matched') return result = self.client.search_track(artist_name, track_name) diff --git a/database/music_database.py b/database/music_database.py index 2b650ada..8bc5684e 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -409,6 +409,12 @@ class MusicDatabase: # Add Discogs enrichment columns (migration) self._add_discogs_columns(cursor) + # Backfill match_status for rows that already have an external ID but + # NULL status. Prevents enrichment workers from re-processing these + # rows forever. Must run AFTER all *_match_status columns have been + # created by the migrations above. + self._backfill_match_status_for_existing_ids(cursor) + # Bubble snapshots table for persisting UI state across page refreshes cursor.execute(""" CREATE TABLE IF NOT EXISTS bubble_snapshots ( @@ -1922,6 +1928,56 @@ class MusicDatabase: except Exception as e: logger.error(f"Error adding Discogs columns: {e}") + def _backfill_match_status_for_existing_ids(self, cursor): + """Set `_match_status = 'matched'` for rows that already have a + populated external ID but NULL match_status. + + Prevents enrichment workers from re-selecting the same rows forever when + the ID was populated outside the worker (file tags, manual match, + pre-migration legacy data) without a corresponding status update. + + Only runs columns that actually exist, so pre-migration databases are + handled safely. UPDATE statements are cheap no-ops when nothing matches. + """ + # (table, id_column, status_column) + targets = [ + ('artists', 'lastfm_url', 'lastfm_match_status'), + ('albums', 'lastfm_url', 'lastfm_match_status'), + ('tracks', 'lastfm_url', 'lastfm_match_status'), + ('artists', 'musicbrainz_id', 'musicbrainz_match_status'), + ('albums', 'musicbrainz_release_id', 'musicbrainz_match_status'), + ('tracks', 'musicbrainz_recording_id', 'musicbrainz_match_status'), + ('artists', 'tidal_id', 'tidal_match_status'), + ('albums', 'tidal_id', 'tidal_match_status'), + ('tracks', 'tidal_id', 'tidal_match_status'), + ('artists', 'qobuz_id', 'qobuz_match_status'), + ('albums', 'qobuz_id', 'qobuz_match_status'), + ('tracks', 'qobuz_id', 'qobuz_match_status'), + ] + + total_backfilled = 0 + for table, id_col, status_col in targets: + try: + cursor.execute(f"PRAGMA table_info({table})") + cols = {row[1] for row in cursor.fetchall()} + if id_col not in cols or status_col not in cols: + continue + cursor.execute( + f"UPDATE {table} SET {status_col} = 'matched' " + f"WHERE {status_col} IS NULL AND {id_col} IS NOT NULL AND {id_col} != ''" + ) + if cursor.rowcount and cursor.rowcount > 0: + total_backfilled += cursor.rowcount + logger.info( + f"Backfilled {cursor.rowcount} rows in {table}.{status_col} " + f"where {id_col} was already set." + ) + except Exception as e: + logger.error(f"Error backfilling {table}.{status_col}: {e}") + + if total_backfilled == 0: + logger.debug("Match-status backfill: no rows needed updating.") + def _add_deezer_columns(self, cursor): """Add Deezer tracking + generic metadata columns for enrichment (artists, albums, tracks)""" try: