From 58df4632c436691779976c5baa1b249fa04f95b6 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Sun, 7 Jun 2026 11:27:28 -0700 Subject: [PATCH] Watchlist: repair iTunes ids that are actually Deezer ids (the 37725457 corruption, proven live) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 37725457 fixed _match_to_itunes to use the real iTunes client and flagged the cross-source corruption as a possibility. Boulder's live DB proves it happened: 6 of his 9 watchlist "iTunes" ids EQUAL the artist's Deezer id (Taylor Swift's "iTunes" id was her Deezer id 12246; the real one is 159260351) — written back when the misnamed MetadataService.itunes slot held a DeezerClient. The June-4 batch (Green Day, SOAD, Vulfpeck, ...) got NULL instead because the slot now holds the Spotify primary. The fix alone can't heal those rows: the backfill only fills EMPTY ids, so a wrong non-empty id is permanent. New migration clears itunes_artist_id where it equals deezer_artist_id (the corruption signature — distinct id spaces, so a legitimate equal pair is effectively impossible, and the worst case is a NULL that re-matches correctly on the next scan). Idempotent by construction; similar_artists checked clean (its backfill always used the registry correctly). Tests: corrupted row cleared / legit + no-deezer rows kept / idempotent — via a real re-init with the per-process init memo cleared (an app restart). --- database/music_database.py | 33 +++++++++++ tests/test_watchlist_itunes_id_repair.py | 72 ++++++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 tests/test_watchlist_itunes_id_repair.py 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