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:
parent
09b97c5f63
commit
afa07690f5
3 changed files with 133 additions and 1 deletions
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -6892,6 +6892,21 @@ class MusicDatabase:
|
|||
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:
|
||||
|
|
|
|||
88
tests/test_base_title_search_fallback.py
Normal file
88
tests/test_base_title_search_fallback.py
Normal 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
|
||||
Loading…
Reference in a new issue