soulsync/tests/test_watchlist_itunes_id_repair.py
BoulderBadgeDad 58df4632c4 Watchlist: repair iTunes ids that are actually Deezer ids (the 37725457 corruption, proven live)
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).
2026-06-07 11:27:28 -07:00

72 lines
2.9 KiB
Python

"""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