From afa07690f5235f0b2746992984c58b048f6b995a Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Sat, 13 Jun 2026 16:55:33 -0700 Subject: [PATCH] Find & Add: match a Spotify 'Title - Remix' query to the base-titled library track MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wolf's report: Spotify shows 'Calma - Remix', Find & Add searches that literal string, but the library stores the track as just 'Calma' (only the 3:58 duration marks it the remix). The literal LIKE '%calma - remix%' misses, so it fell to the OR-fuzzy fallback which floods on the common word 'remix' (20 unrelated '... remix' hits). Dropping '- Remix' (searching 'Calma') finds it instantly. Fix: search_tracks (and api_search_tracks) now retry on the BASE title — the part before Spotify's ' - ' version separator — BEFORE the OR-fuzzy flood. So 'Calma - Remix' resolves to 'Calma' (or 'Calma (Remix)') and the noise fallback is never reached when the base matches. New core.text.title_match.base_title_before_dash (splits the first spaced ' - '; leaves bare hyphens like 'Up-Tight' alone). Tests: pure helper (3) + real-DB integration reproducing the Calma case, the parenthesized-remix variant, plain-title-unaffected, and no-flood (4). 64 search/match tests green. --- core/text/title_match.py | 18 +++++ database/music_database.py | 28 +++++++- tests/test_base_title_search_fallback.py | 88 ++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 tests/test_base_title_search_fallback.py diff --git a/core/text/title_match.py b/core/text/title_match.py index 85e1c10b..36818fcd 100644 --- a/core/text/title_match.py +++ b/core/text/title_match.py @@ -201,9 +201,27 @@ def numeric_tokens_differ(title_a: str, title_b: str) -> bool: return _digit_tokens(title_a) != _digit_tokens(title_b) +def base_title_before_dash(title: str) -> str: + """The base title before Spotify's ' - ' version separator. + + Spotify renders versions as 'Calma - Remix' / 'Song - Radio Edit' / + 'Track - Remastered 2019'. Libraries (and the files people actually have) + very often store just the base — 'Calma' — so a literal search for + 'Calma - Remix' finds nothing and the OR-fuzzy fallback then floods on the + common qualifier word ('remix' matches every remix). This returns the base + ('Calma') for a base-title search fallback. Splits on the FIRST ' - ' (the + spaced hyphen is Spotify's separator; a bare hyphen inside a word is left + alone). Returns the title unchanged when there's no separator.""" + if not title: + return title + idx = title.find(' - ') + return title[:idx].strip() if idx > 0 else title + + __all__ = [ "titles_plausibly_same", "strip_redundant_context_qualifiers", "strip_subtitle_qualifiers", "numeric_tokens_differ", + "base_title_before_dash", ] diff --git a/database/music_database.py b/database/music_database.py index 0d06a6db..1f6078fb 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -6887,11 +6887,26 @@ class MusicDatabase: # STRATEGY 1: Try basic SQL LIKE search first (fastest) basic_results = self._search_tracks_basic(cursor, title, artist, limit, server_source, rank_artist) - + if basic_results: logger.debug(f"Basic search found {len(basic_results)} results") return basic_results + # STRATEGY 1b: Spotify renders versions as "Title - Qualifier" + # ("Calma - Remix") but libraries usually store just the base + # ("Calma"), so the literal search misses. Retry on the base title + # BEFORE the OR-fuzzy fallback (which would flood on the common + # qualifier word — every "... remix" matches "remix"). #: Calma - Remix + if title: + from core.text.title_match import base_title_before_dash + base_title = base_title_before_dash(title) + if base_title and base_title != title: + base_results = self._search_tracks_basic( + cursor, base_title, artist, limit, server_source, rank_artist) + if base_results: + logger.debug("Base-title search matched '%s' via '%s'", title, base_title) + return base_results + # STRATEGY 2: Broader fuzzy search - splits into individual words with OR matching fuzzy_results = self._search_tracks_fuzzy_fallback(cursor, title, artist, limit, server_source) if fuzzy_results: @@ -6920,6 +6935,17 @@ class MusicDatabase: if basic_rows: return [dict(r) for r in basic_rows] + # Base-title fallback for Spotify "Title - Qualifier" forms (see + # search_tracks STRATEGY 1b) before the OR-fuzzy flood. + if title: + from core.text.title_match import base_title_before_dash + base_title = base_title_before_dash(title) + if base_title and base_title != title: + base_rows = self._search_tracks_basic_rows( + cursor, base_title, artist, limit, server_source) + if base_rows: + return [dict(r) for r in base_rows] + fuzzy_rows = self._search_tracks_fuzzy_rows(cursor, title, artist, limit, server_source) return [dict(r) for r in fuzzy_rows] except Exception as e: diff --git a/tests/test_base_title_search_fallback.py b/tests/test_base_title_search_fallback.py new file mode 100644 index 00000000..f9c2b936 --- /dev/null +++ b/tests/test_base_title_search_fallback.py @@ -0,0 +1,88 @@ +"""Find & Add: Spotify "Title - Qualifier" must find the base-titled library track. + +wolf's report: Spotify shows "Calma - Remix", Find & Add searches that literal +string, the library stores the track as just "Calma" (only the duration marks it +as the remix) → the literal search misses and the OR-fuzzy fallback floods 20 +unrelated "... remix" hits. Dropping "- Remix" (searching "Calma") finds it. + +Fix: search_tracks retries on the base title (before Spotify's " - " separator) +before the OR-fuzzy flood. +""" + +from __future__ import annotations + +import pytest + +from core.text.title_match import base_title_before_dash +from database.music_database import MusicDatabase + + +# --- pure helper ----------------------------------------------------------- + +def test_base_title_before_dash_strips_spotify_version_suffix(): + assert base_title_before_dash('Calma - Remix') == 'Calma' + assert base_title_before_dash('Closer - Radio Edit') == 'Closer' + assert base_title_before_dash('Crocodile Rock - Remastered 2014') == 'Crocodile Rock' + + +def test_base_title_before_dash_leaves_plain_titles_alone(): + assert base_title_before_dash('Tom Sawyer') == 'Tom Sawyer' + assert base_title_before_dash('21st Century Schizoid Man') == '21st Century Schizoid Man' + assert base_title_before_dash('Up-Tight') == 'Up-Tight' # bare hyphen, not a separator + assert base_title_before_dash('') == '' + + +def test_base_title_before_dash_splits_first_separator_only(): + assert base_title_before_dash('A - B - C') == 'A' + + +# --- integration: the real search path ------------------------------------- + +@pytest.fixture +def db(tmp_path): + return MusicDatabase(str(tmp_path / "music.db")) + + +def _insert(db, tid, title, artist_id, artist_name): + with db._get_connection() as conn: + conn.execute("INSERT OR IGNORE INTO artists (id, name) VALUES (?, ?)", (artist_id, artist_name)) + conn.execute("INSERT OR IGNORE INTO albums (id, title, artist_id) VALUES (?, ?, ?)", + (artist_id, "Alb", artist_id)) + conn.execute( + "INSERT INTO tracks (id, album_id, artist_id, title, track_number, duration, file_path) " + "VALUES (?, ?, ?, ?, 1, 238, ?)", + (tid, artist_id, artist_id, title, f"/m/{tid}.flac"), + ) + conn.commit() + + +def test_spotify_dash_remix_finds_base_titled_track(db): + # Library stores the remix as just "Calma" (the wolf case). + _insert(db, 1, "Calma", 1, "Pedro Capó") + results = db.search_tracks(title="Calma - Remix", rank_artist="Pedro Capó") + titles = [t.title for t in results] + assert "Calma" in titles, "base-title fallback should find 'Calma' for 'Calma - Remix'" + + +def test_spotify_dash_remix_finds_parenthesized_remix(db): + # …and still matches when the library DID label it "(Remix)". + _insert(db, 1, "Calma (Remix)", 1, "Pedro Capó") + results = db.search_tracks(title="Calma - Remix", rank_artist="Pedro Capó") + assert any("Calma" in t.title for t in results) + + +def test_plain_title_unaffected_uses_basic_search(db): + _insert(db, 1, "Tom Sawyer", 1, "Rush") + results = db.search_tracks(title="Tom Sawyer") + assert [t.title for t in results] == ["Tom Sawyer"] + + +def test_dash_query_does_not_flood_when_base_matches(db): + # The base-title retry must short-circuit BEFORE the OR-fuzzy flood, so an + # unrelated "... Remix" track doesn't drown the real one. + _insert(db, 1, "Calma", 1, "Pedro Capó") + _insert(db, 2, "Some Other Song (KAIZ Remix)", 2, "Someone Else") + results = db.search_tracks(title="Calma - Remix", rank_artist="Pedro Capó") + titles = [t.title for t in results] + assert "Calma" in titles + assert "Some Other Song (KAIZ Remix)" not in titles