fix: stop enrichment workers from re-processing rows forever

Four enrichment workers (Last.fm, MusicBrainz, Tidal, Qobuz) had a

bug where every background loop re-processed the same rows because

the existing-ID short-circuit path never set match_status, and two

workers queried the wrong column when checking for an existing ID.

lastfm_worker._get_existing_id queried a non-existent lastfm_id

column; the real column is lastfm_url. The method now reads

lastfm_url for all three entity types.

musicbrainz_worker._get_existing_id queried musicbrainz_id for all

entity types, but albums use musicbrainz_release_id and tracks use

musicbrainz_recording_id. The method now uses a per-type column map.

All four workers (lastfm, musicbrainz, tidal, qobuz) now write

match_status='matched' when they short-circuit on an already-present

external ID, so these rows are no longer re-selected on the next

worker sweep.

A new migration (_backfill_match_status_for_existing_ids) runs once

on startup to retroactively set match_status='matched' for rows that

already have an external ID but NULL match_status. This covers legacy

data, manual matches, and rows populated from file tags outside the

worker.
This commit is contained in:
JohnBaumb 2026-04-19 14:44:56 -07:00
parent b1c2e89595
commit f4c8c231a7
5 changed files with 106 additions and 10 deletions

View file

@ -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)

View file

@ -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':

View file

@ -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)

View file

@ -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)

View file

@ -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 `<provider>_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: