Find & Add: match a Spotify 'Title - Remix' query to the base-titled library track

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.
This commit is contained in:
BoulderBadgeDad 2026-06-13 16:55:33 -07:00
parent 09b97c5f63
commit afa07690f5
3 changed files with 133 additions and 1 deletions

View file

@ -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 ' - <qualifier>' 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",
]

View file

@ -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:

View file

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