diff --git a/database/music_database.py b/database/music_database.py index 6d95d422..17d2e41c 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -396,6 +396,7 @@ class MusicDatabase: # Add per-artist preferred_metadata_source column (migration) self._add_watchlist_preferred_metadata_source_column(cursor) + self._clear_deezer_ids_stored_as_itunes(cursor) # Make spotify_artist_id nullable for iTunes-only artists (migration) self._fix_watchlist_spotify_id_nullable(cursor) @@ -2114,6 +2115,38 @@ class MusicDatabase: except Exception as e: logger.error(f"Error adding preferred_metadata_source column to watchlist_artists: {e}") + def _clear_deezer_ids_stored_as_itunes(self, cursor): + """Repair: watchlist iTunes ids that are actually Deezer ids. + + The watchlist scanner's _match_to_itunes used to search via + MetadataService.itunes — which holds the PRIMARY source's client, not + iTunes — so with a Deezer primary it stored DEEZER artist ids in + itunes_artist_id (verified live: Taylor Swift's "iTunes" id was her + Deezer id 12246; real one is 159260351). The backfill only fills + EMPTY ids, so these wrong ids would never self-heal. The corruption + signature is itunes == deezer (distinct id spaces — a legit equal + pair is effectively impossible; worst case is a NULL that re-matches + correctly on the next scan). Idempotent: clearing kills the equality. + """ + try: + cursor.execute("PRAGMA table_info(watchlist_artists)") + columns = [column[1] for column in cursor.fetchall()] + if 'itunes_artist_id' not in columns or 'deezer_artist_id' not in columns: + return + cursor.execute(""" + UPDATE watchlist_artists SET itunes_artist_id = NULL + WHERE itunes_artist_id = deezer_artist_id + AND deezer_artist_id IS NOT NULL AND deezer_artist_id != '' + """) + if cursor.rowcount: + logger.info( + "Cleared %d watchlist iTunes id(s) that were actually Deezer ids " + "(pre-fix _match_to_itunes searched the primary source); they " + "re-match via real iTunes on the next watchlist scan", + cursor.rowcount) + except Exception as e: + logger.error(f"Error clearing deezer-as-itunes watchlist ids: {e}") + def _add_similar_artists_last_featured_column(self, cursor): """Add last_featured column to similar_artists for hero slider cycling""" try: diff --git a/tests/test_watchlist_itunes_id_repair.py b/tests/test_watchlist_itunes_id_repair.py new file mode 100644 index 00000000..2db22990 --- /dev/null +++ b/tests/test_watchlist_itunes_id_repair.py @@ -0,0 +1,72 @@ +"""Repair migration: watchlist iTunes ids that are actually Deezer ids. + +_match_to_itunes used to search the PRIMARY source's client (the misnamed +MetadataService.itunes slot) and store that source's artist id in the +itunes column — with a Deezer primary, Deezer ids landed as "iTunes" ids +(verified in a live DB: 6 of 9 rows). Since the backfill only fills EMPTY +ids, the migration must clear the corrupted ones (signature: itunes == +deezer) so the fixed matcher can re-fill them. +""" + +from __future__ import annotations + +import sqlite3 + +import database.music_database as mdb +from database.music_database import MusicDatabase + + +def _open_raw(db_path): + return sqlite3.connect(db_path) + + +def _reinit(db_path): + """Schema init runs once per process per path (module memo) — clear the + memo so re-construction replays the migration block like a real app + restart (fresh process) would.""" + mdb._database_initialized_paths.clear() + return MusicDatabase(db_path) + + +def test_deezer_as_itunes_ids_cleared_and_legit_kept(tmp_path): + db_path = str(tmp_path / 'm.db') + MusicDatabase(db_path) # create schema (migration runs, table empty) + + conn = _open_raw(db_path) + c = conn.cursor() + c.execute("""INSERT INTO watchlist_artists (artist_name, spotify_artist_id, itunes_artist_id, deezer_artist_id) + VALUES ('Taylor Swift', 'sp1', '12246', '12246')""") # corrupted + c.execute("""INSERT INTO watchlist_artists (artist_name, spotify_artist_id, itunes_artist_id, deezer_artist_id) + VALUES ('Eminem', 'sp2', '111051', '13')""") # legit + c.execute("""INSERT INTO watchlist_artists (artist_name, spotify_artist_id, itunes_artist_id, deezer_artist_id) + VALUES ('NoDeezer', 'sp3', '999', NULL)""") # no deezer id -> keep + conn.commit() + conn.close() + + _reinit(db_path) # like an app restart -> migration sweeps existing rows + + conn = _open_raw(db_path) + c = conn.cursor() + c.execute("SELECT artist_name, itunes_artist_id FROM watchlist_artists ORDER BY artist_name") + got = dict(c.fetchall()) + conn.close() + + assert got['Taylor Swift'] is None # corruption cleared + assert got['Eminem'] == '111051' # real id untouched + assert got['NoDeezer'] == '999' # equality needs a deezer id + + +def test_migration_idempotent(tmp_path): + db_path = str(tmp_path / 'm.db') + MusicDatabase(db_path) + conn = _open_raw(db_path) + conn.execute("""INSERT INTO watchlist_artists (artist_name, spotify_artist_id, itunes_artist_id, deezer_artist_id) + VALUES ('A', 'sp', '5', '5')""") + conn.commit() + conn.close() + _reinit(db_path) + _reinit(db_path) # second run: nothing left to clear, no error + conn = _open_raw(db_path) + val = conn.execute("SELECT itunes_artist_id FROM watchlist_artists").fetchone()[0] + conn.close() + assert val is None