soulsync/tests/test_worker_artist_match_gate.py
BoulderBadgeDad 0af99881bf Tighten artist matching: 0.85 gate + shared uniqueness guard
Two complementary fixes to stop distinct artists ending up with the same source
id (the near-name collisions: ODESZA/odessa, Blance/Blanke, Lady A/Lady Gaga,
plus MusicBrainz's combined-score weak matches like Grant/Amy Grant):

- core/worker_utils.accept_artist_match() / source_id_conflict(): one shared,
  tested gate. Rejects artist matches below 0.85 (stricter than the 0.80 used
  for album/track titles, since short artist names false-positive easily) AND
  refuses to store a source id a DIFFERENTLY-named artist already holds. A
  same-named holder (one act across two media servers) is still allowed.

- Routed every artist-match worker through it: deezer, qobuz, tidal, discogs,
  itunes, spotify (its scorer now uses the 0.85 threshold), audiodb, and
  musicbrainz (conflict guard only — its matcher is combined-score, so the
  guard is the net that catches its weak-name matches).

Centralizing in worker_utils avoids the copy-paste that let the original
album/track overwrite bug live in four workers at once. 17 new gate tests.
2026-06-05 10:01:17 -07:00

114 lines
4 KiB
Python

"""Tests for the shared artist-match gate in core/worker_utils.py.
Two jobs:
* artist_name_matches — a stricter (0.85) gate than the 0.80 used for
album/track titles, so short-name false positives ('ODESZA'/'odessa',
'Blance'/'Blanke', 'Lady A'/'Lady Gaga') are rejected.
* source_id_conflict / accept_artist_match — refuse to store a source id that
a DIFFERENTLY-named artist already holds, while still allowing a same-named
artist (the same act indexed on two media servers) to share it.
"""
from __future__ import annotations
import pytest
from core import worker_utils as wu
from database.music_database import MusicDatabase
@pytest.fixture
def db(tmp_path):
return MusicDatabase(str(tmp_path / "music.db"))
def _insert(db, *, artist_id, name, **extra):
cols = ["id", "name", "server_source"] + list(extra.keys())
vals = [artist_id, name, "plex"] + list(extra.values())
ph = ",".join("?" for _ in cols)
with db._get_connection() as conn:
conn.execute(f"INSERT INTO artists ({','.join(cols)}) VALUES ({ph})", vals)
conn.commit()
# ---------------------------------------------------------------------------
# artist_name_matches — the 0.85 gate
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("a,b", [
("ODESZA", "odessa"),
("Blance", "Blanke"),
("COLLEGE", "Colle"),
("Lady A", "Lady Gaga"),
("M&O", "M.O.P."),
])
def test_near_name_pairs_rejected_at_085(a, b):
assert wu.artist_name_matches(a, b) is False
@pytest.mark.parametrize("a,b", [
("Saib", "saib."), # punctuation only → identical
("-Us.", "Us"),
("Kendrick Lamar", "KENDRICK LAMAR"),
("Beyoncé", "Beyonce"),
])
def test_true_variants_still_match(a, b):
assert wu.artist_name_matches(a, b) is True
# ---------------------------------------------------------------------------
# source_id_conflict — different name blocks, same name allowed
# ---------------------------------------------------------------------------
def test_conflict_when_different_named_artist_holds_id(db):
_insert(db, artist_id="1", name="Kendrick Lamar", deezer_id="525046")
# Trying to give Jorja the same id → conflict reports the holder.
assert wu.source_id_conflict(db, "deezer_id", "525046", "2", "Jorja Smith") == "Kendrick Lamar"
def test_no_conflict_for_same_named_artist_two_servers(db):
# Radiohead already on plex with this id; the jellyfin Radiohead (id 2) may
# legitimately share it.
_insert(db, artist_id="1", name="Radiohead", deezer_id="999")
assert wu.source_id_conflict(db, "deezer_id", "999", "2", "Radiohead") is None
def test_no_conflict_when_same_row_holds_it(db):
_insert(db, artist_id="1", name="Radiohead", deezer_id="999")
# Re-matching the same artist to its own id is fine.
assert wu.source_id_conflict(db, "deezer_id", "999", "1", "Radiohead") is None
def test_no_conflict_when_id_unused(db):
assert wu.source_id_conflict(db, "deezer_id", "12345", "1", "Anyone") is None
def test_unknown_column_is_refused(db):
assert wu.source_id_conflict(db, "evil_id", "x", "1", "Anyone") is None
# ---------------------------------------------------------------------------
# accept_artist_match — combined gate
# ---------------------------------------------------------------------------
def test_accept_rejects_name_mismatch(db):
ok, reason = wu.accept_artist_match(db, "deezer_id", "1", "1", "ODESZA", "odessa")
assert ok is False
assert "name mismatch" in reason
def test_accept_rejects_id_already_claimed_by_other(db):
_insert(db, artist_id="1", name="Kendrick Lamar", deezer_id="525046")
ok, reason = wu.accept_artist_match(
db, "deezer_id", "525046", "2", "Jorja Smith", "Jorja Smith"
)
assert ok is False
assert "already claimed" in reason
def test_accept_passes_clean_match(db):
ok, reason = wu.accept_artist_match(
db, "deezer_id", "111", "1", "Kendrick Lamar", "Kendrick Lamar"
)
assert ok is True
assert reason == ""