diff --git a/core/text/title_match.py b/core/text/title_match.py new file mode 100644 index 00000000..ed8ca4a2 --- /dev/null +++ b/core/text/title_match.py @@ -0,0 +1,81 @@ +"""Guard against char-level title false positives in track matching. + +Issue #769: playlist sync matched tracks that aren't in the library to a +DIFFERENT song by the SAME artist, with high confidence — e.g. "Dani +California" -> "Californication" (Red Hot Chili Peppers), "Under The Bridge" +-> "Around the World". The confidence formula is ``0.5*title + 0.5*artist``, +and a same-artist comparison always yields ``artist = 1.0``, so the title score +is the only thing that can tell two of an artist's songs apart. But the title +score is a ``difflib.SequenceMatcher`` character ratio, which over-credits +unrelated titles that happen to share a long substring ("californi…") or only a +stopword ("the"): 0.67 and 0.62 respectively. With the flat 0.5 artist term +that lands at 0.83 / 0.81 — well over the 0.7 sync threshold. + +``titles_plausibly_same`` adds a cheap word-level sanity check on top of the +char ratio: accept a pair only when it's near-identical char-wise (so typos and +punctuation/casing variants — "Beleive"/"Believe", "HUMBLE."/"Humble" — still +match) OR the two titles share at least one significant (non-stopword) token. +Two genuinely different songs by the same artist share no content word, so they +get rejected; the real track is then correctly reported missing. +""" + +from __future__ import annotations + +import re + +# Articles / prepositions / conjunctions only. Deliberately NOT pronouns +# ("you", "me", "i") — those carry meaning in song titles and dropping them +# could strip the only shared word from a real match. "the" MUST stay here: +# without it "Under The Bridge" and "Around the World" would falsely share it. +_TITLE_STOPWORDS = frozenset({ + "the", "a", "an", "of", "and", "or", "to", "in", "on", + "for", "with", "at", "by", "from", +}) + +_TOKEN_RE = re.compile(r"[a-z0-9]+") + +# Char ratio at/above which two titles are treated as the same regardless of +# shared words — covers typos, punctuation, casing, accents. Tuned so single- +# word typos ("Beleive"/"Believe" = 0.857) pass while the #769 false positives +# ("Dani California"/"Californication" = 0.667) do not. +_NEAR_IDENTICAL = 0.85 + + +def _content_tokens(text: str) -> set[str]: + return {t for t in _TOKEN_RE.findall((text or "").lower()) if t not in _TITLE_STOPWORDS} + + +def titles_plausibly_same( + title_a: str, + title_b: str, + char_similarity: float, + *, + near_identical: float = _NEAR_IDENTICAL, +) -> bool: + """Whether two titles could be the same track, given their char similarity. + + ``title_a`` / ``title_b`` should already be normalised/cleaned (lowercased, + brackets stripped) the same way the caller computed ``char_similarity``. + + Returns ``True`` when the pair is near-identical char-wise OR shares at + least one significant (non-stopword) token. Returns ``False`` for two + titles that are only moderately char-similar and share no content word — + i.e. different songs the char ratio over-credited (#769).""" + if char_similarity >= near_identical: + return True + ta = _content_tokens(title_a) + tb = _content_tokens(title_b) + # Word-overlap is only a reliable "different song" signal when at least one + # side has 2+ content words — that's the #769 case where the char ratio + # over-credits a shared substring ("Dani California"/"Californication") or + # a stopword ("Under The Bridge"/"Around the World"). For single-word + # titles there's no other word to share, so applying it would wrongly fail + # legitimate stylized spellings ("Grey"/"Gray", "Tonite"/"Tonight", + # "Thru"/"Through") that the char ratio rightly accepts. In that case defer + # to the caller's existing char-similarity floor instead of force-failing. + if max(len(ta), len(tb)) < 2 or not ta or not tb: + return True + return not ta.isdisjoint(tb) + + +__all__ = ["titles_plausibly_same"] diff --git a/database/music_database.py b/database/music_database.py index 85beef86..08d42d14 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -7248,6 +7248,22 @@ class MusicDatabase: # Titles differ in length by more than 30% — penalize heavily best_title_similarity *= len_ratio + # Word-level guard: SequenceMatcher's char ratio over-credits + # different songs that share a long substring or only a stopword + # ("Dani California" vs "Californication" = 0.67; "Under The Bridge" + # vs "Around the World" = 0.62). Since a same-artist comparison + # always scores artist = 1.0, the title is the only discriminator, + # so a bad-but-moderate title score gets carried over the threshold + # (#769). Reject pairs that aren't near-identical AND share no + # significant word — the real track is then reported missing. + from core.text.title_match import titles_plausibly_same + if not titles_plausibly_same( + clean_search_title or search_title_norm, + clean_db_title or db_title_norm, + best_title_similarity, + ): + return best_title_similarity * 0.5 # below any threshold + # Require minimum title similarity to prevent a perfect artist match from # carrying a bad title match over the threshold (e.g. "Time" vs "Time Flies") if best_title_similarity < 0.6: diff --git a/tests/test_title_match_guard.py b/tests/test_title_match_guard.py new file mode 100644 index 00000000..49fb5a6a --- /dev/null +++ b/tests/test_title_match_guard.py @@ -0,0 +1,153 @@ +"""Tests for the title word-overlap guard (#769). + +Playlist sync matched tracks NOT in the library to a different song by the +SAME artist with high confidence ("Dani California" -> "Californication"; +"Under The Bridge" -> "Around the World"). Root cause: confidence is +0.5*title + 0.5*artist, same-artist always gives artist=1.0, and the title +score is a SequenceMatcher char ratio that over-credits unrelated titles +sharing a substring or a stopword. titles_plausibly_same gates those out. + +Two layers tested: + 1. titles_plausibly_same in isolation (the pure decision). + 2. the real _calculate_track_confidence end-to-end, asserting the two + reported false positives now fall below the 0.7 sync threshold while a + battery of genuine matches stays above it. +""" + +from __future__ import annotations + +import types + +from core.text.title_match import titles_plausibly_same + + +# ── the pure guard ──────────────────────────────────────────────────────── + + +def test_near_identical_passes_even_without_shared_token(): + # Single-word typo: no shared token, but char-identical enough. + assert titles_plausibly_same("beleive", "believe", 0.857) is True + + +def test_punctuation_casing_variants_pass(): + assert titles_plausibly_same("humble", "humble", 0.92) is True + + +def test_shared_significant_word_passes_below_near_identical(): + # Moderate char score but a real shared content word. + assert titles_plausibly_same("hello world", "hello there", 0.6) is True + + +def test_different_songs_sharing_only_substring_rejected(): + # #769: "Dani California" vs "Californication" — share the substring + # "californi" (high char ratio) but no whole word. + assert titles_plausibly_same("dani california", "californication", 0.667) is False + + +def test_different_songs_sharing_only_stopword_rejected(): + # #769: "Under The Bridge" vs "Around the World" — share only "the". + assert titles_plausibly_same("under the bridge", "around the world", 0.625) is False + + +def test_multiword_stopword_only_overlap_rejected(): + # Two 2+-word titles sharing only "the" — the #769 shape. + assert titles_plausibly_same("under the bridge", "around the world", 0.625) is False + + +def test_single_word_titles_defer_to_char_floor(): + # Single content word on each side: no "other word" to share, so the gate + # must NOT force-fail — it defers (returns True) and lets the caller's char + # floor decide. This is what protects stylized spellings like "Grey"/"Gray" + # and "Tonite"/"Tonight" from becoming new false negatives. + assert titles_plausibly_same("grey", "gray", 0.75) is True + assert titles_plausibly_same("tonite", "tonight", 0.77) is True + # ...even when the char score is low — the floor, not the gate, rejects it. + assert titles_plausibly_same("numb", "creep", 0.2) is True + + +def test_all_stopword_side_defers(): + # One side is all stopwords -> no word signal -> defer to char floor. + assert titles_plausibly_same("the the", "around the world", 0.5) is True + + +# ── end-to-end through the real confidence scorer ────────────────────────── + +from database.music_database import MusicDatabase # noqa: E402 + +_THRESHOLD = 0.7 # services/sync_service.py confidence_threshold + + +class _FakeTrack: + def __init__(self, title, artist): + self.title = title + self.artist_name = artist + self.track_artist = None + + +def _scorer(): + stub = type("S", (), {})() + for m in ( + "_calculate_track_confidence", "_string_similarity", + "_normalize_for_comparison", "_clean_track_title_for_comparison", + ): + setattr(stub, m, types.MethodType(getattr(MusicDatabase, m), stub)) + return stub + + +# (source_title, library_title, same_artist, should_match) +_BATTERY = [ + # genuine matches — must stay matched + ("Mr. Brightside", "Mr Brightside", True), + ("HUMBLE.", "Humble", True), + ("Beleive", "Believe", True), # typo + ("In the End", "In The End", True), + ("thank u, next", "Thank U Next", True), + ("Old Town Road", "Old Town Road (feat. Billy Ray Cyrus)", True), + ("bad guy", "bad guy", True), + # different songs by the SAME artist — must be reported missing + ("Dani California", "Californication", False), # the reported case + ("Under The Bridge", "Around the World", False), # the reported case + ("Otherside", "Californication", False), + ("Numb", "In the End", False), + ("Yellow", "The Scientist", False), + ("Seven Nation Army", "Fell in Love with a Girl", False), +] + + +def test_confidence_battery_separates_real_from_false_matches(): + s = _scorer() + artist = "Red Hot Chili Peppers" + misclassified = [] + for src, lib, should_match in _BATTERY: + conf = s._calculate_track_confidence(src, artist, _FakeTrack(lib, artist)) + matched = conf >= _THRESHOLD + if matched != should_match: + misclassified.append((src, lib, should_match, round(conf, 3))) + assert not misclassified, f"misclassified: {misclassified}" + + +def test_reported_false_positives_now_below_threshold(): + s = _scorer() + a = "Red Hot Chili Peppers" + assert s._calculate_track_confidence("Dani California", a, _FakeTrack("Californication", a)) < _THRESHOLD + assert s._calculate_track_confidence("Under The Bridge", a, _FakeTrack("Around the World", a)) < _THRESHOLD + + +def test_exact_title_same_artist_still_perfect(): + s = _scorer() + a = "Garbage" + conf = s._calculate_track_confidence("Only Happy When It Rains", a, + _FakeTrack("Only Happy When It Rains", a)) + assert conf >= 0.99 + + +def test_single_word_spelling_variants_not_regressed(): + # The gate must not turn legitimate stylized single-word spellings into + # new "missing" reports (the regression the first cut of this fix had). + # These all matched before #769's gate and must still match. + s = _scorer() + a = "Some Artist" + for src, lib in [("Grey", "Gray"), ("Tonite", "Tonight"), + ("4ever", "Forever"), ("Lovin'", "Loving"), ("Colour", "Color")]: + conf = s._calculate_track_confidence(src, a, _FakeTrack(lib, a)) + assert conf >= _THRESHOLD, f"{src!r}->{lib!r} regressed to {conf:.3f}"