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).
72 lines
2.9 KiB
Python
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
|