Fix: Find & Add library search buried exact matches (case-sensitive ordering)

Reported via Find & Add (Billie Eilish "bad guy"): the track was in the library
and on Plex, but never showed in the modal's 20 results. Root cause (proven
against the real 307k-track DB): the search did `ORDER BY tracks.title`, which is
case-SENSITIVE in SQLite (BINARY collation sorts 'B' before 'b'). Billie's title
is lowercase "bad guy"; everyone else's is "Bad Guy", so all the capitalised ones
sorted first, filled the LIMIT, and her exact match landed at ~#25 — cut off.

- search_tracks now ranks by relevance: exact title match first (case-insensitive
  via unidecode_lower), then prefix, then alphabetical — so an exact match can't
  be sorted below the limit by a capital letter. Helps every caller.
- Added a rank-only `rank_artist` hint (never filters): Find & Add already knows
  the source track's artist, so it now passes it and the exact title+artist match
  floats to #1. Filtering was deliberately avoided — if the track is tagged under
  a slightly different artist on the server, a filter would re-hide it.

Verified on the real DB: title-only "bad guy" now surfaces Billie at #4 (was
>#20); with the artist hint she's #1. Seam tests: lowercase exact title isn't
buried; rank hint floats the match without filtering; exact title beats a
superstring title. 10 tests pass.
This commit is contained in:
BoulderBadgeDad 2026-06-10 17:23:13 -07:00
parent 1517794e23
commit 27d738e7b1
4 changed files with 127 additions and 12 deletions

View file

@ -6631,17 +6631,22 @@ class MusicDatabase:
logger.error(f"Error searching artists with query '{query}': {e}")
return []
def search_tracks(self, title: str = "", artist: str = "", limit: int = 50, server_source: str = None) -> List[DatabaseTrack]:
"""Search tracks by title and/or artist name with Unicode-aware fuzzy matching"""
def search_tracks(self, title: str = "", artist: str = "", limit: int = 50, server_source: str = None,
rank_artist: str = None) -> List[DatabaseTrack]:
"""Search tracks by title and/or artist name with Unicode-aware fuzzy matching.
``rank_artist`` is a relevance-only hint (never filters): when given, rows
by that artist rank to the top so an exact title+artist match wins over
same-title tracks by other artists."""
try:
if not title and not artist:
return []
conn = self._get_connection()
cursor = conn.cursor()
# STRATEGY 1: Try basic SQL LIKE search first (fastest)
basic_results = self._search_tracks_basic(cursor, title, artist, limit, server_source)
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")
@ -6681,14 +6686,18 @@ class MusicDatabase:
logger.error(f"API: Error searching tracks with title='{title}', artist='{artist}': {e}")
return []
def _search_tracks_basic(self, cursor, title: str, artist: str, limit: int, server_source: str = None) -> List[DatabaseTrack]:
def _search_tracks_basic(self, cursor, title: str, artist: str, limit: int, server_source: str = None,
rank_artist: str = None) -> List[DatabaseTrack]:
"""Basic SQL LIKE search - fastest method"""
rows = self._search_tracks_basic_rows(cursor, title, artist, limit, server_source)
rows = self._search_tracks_basic_rows(cursor, title, artist, limit, server_source, rank_artist)
return self._rows_to_tracks(rows)
def _search_tracks_basic_rows(self, cursor, title: str, artist: str, limit: int,
server_source: Optional[str] = None):
"""Basic SQL LIKE search returning raw rows (shared by DatabaseTrack and dict-returning callers)."""
server_source: Optional[str] = None, rank_artist: Optional[str] = None):
"""Basic SQL LIKE search returning raw rows (shared by DatabaseTrack and dict-returning callers).
``rank_artist`` is a relevance-only hint (does NOT filter): when given,
rows by that artist sort to the top so an exact title+artist match wins."""
where_conditions = []
params = []
@ -6711,6 +6720,33 @@ class MusicDatabase:
return []
where_clause = " AND ".join(where_conditions)
# Relevance ordering. The old `ORDER BY tracks.title` was case-SENSITIVE
# (SQLite BINARY collation sorts 'B' before 'b'), so a lowercase exact
# title like Billie Eilish's "bad guy" sorted BELOW every capitalised
# "Bad Guy" and got cut off by LIMIT. Now: exact title match first, then
# prefix, then — when an artist is given — exact/contains artist match,
# finally case-insensitive alphabetical. unidecode_lower folds case +
# accents, matching the WHERE clause.
order_parts, order_params = [], []
if title:
norm_title = self._normalize_for_comparison(title)
order_parts.append(
"CASE WHEN unidecode_lower(tracks.title) = ? THEN 0 "
"WHEN unidecode_lower(tracks.title) LIKE ? THEN 1 ELSE 2 END")
order_params.extend([norm_title, f"{norm_title}%"])
_rank_artist = artist or rank_artist
if _rank_artist:
norm_artist = self._normalize_for_comparison(_rank_artist)
order_parts.append(
"CASE WHEN unidecode_lower(artists.name) = ? THEN 0 "
"WHEN unidecode_lower(artists.name) LIKE ? THEN 1 ELSE 2 END")
order_params.extend([norm_artist, f"%{norm_artist}%"])
order_parts.append("unidecode_lower(tracks.title)")
order_parts.append("unidecode_lower(artists.name)")
order_by = ", ".join(order_parts)
params.extend(order_params)
params.append(limit)
cursor.execute(f"""
@ -6719,7 +6755,7 @@ class MusicDatabase:
JOIN artists ON tracks.artist_id = artists.id
JOIN albums ON tracks.album_id = albums.id
WHERE {where_clause}
ORDER BY tracks.title, artists.name
ORDER BY {order_by}
LIMIT ?
""", params)

View file

@ -0,0 +1,68 @@
"""Find & Add library search relevance (Billie Eilish 'bad guy' report).
Root cause proven against the real DB: `ORDER BY tracks.title` is case-SENSITIVE
(SQLite BINARY sorts 'B' before 'b'), so a lowercase exact title like Billie
Eilish's "bad guy" sorted BELOW every capitalised "Bad Guy" and fell past the
result LIMIT it never showed in the modal even though it was in the library.
Fix: rank by relevance (exact title first, case-insensitive), and accept a
rank-only artist hint so an exact title+artist match wins without FILTERING
(filtering would re-hide the track if it's tagged under a slightly different
artist on the server).
"""
from __future__ import annotations
import pytest
from database.music_database import MusicDatabase
@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, 180, ?)",
(tid, artist_id, artist_id, title, f"/m/{tid}.mp3"),
)
conn.commit()
def test_lowercase_exact_title_not_buried_by_case(db):
# Capitalised "Bad Guy" tracks would (pre-fix) fill the LIMIT and sort
# the lowercase "bad guy" below them, cutting it off.
_insert(db, 1, "Bad Guy", 1, "Yara")
_insert(db, 2, "Bad Guy", 2, "Zelda")
_insert(db, 3, "bad guy", 3, "Billie Eilish")
names = [t.artist_name for t in db.search_tracks(title="bad guy", limit=2)]
assert "Billie Eilish" in names, "lowercase exact title must not be sorted past the limit"
def test_rank_artist_hint_floats_match_to_top_without_filtering(db):
_insert(db, 1, "Bad Guy", 1, "Aaa Artist")
_insert(db, 2, "Bad Guy", 2, "Bbb Artist")
_insert(db, 3, "bad guy", 3, "Billie Eilish")
results = db.search_tracks(title="bad guy", limit=10, rank_artist="Billie Eilish")
names = [t.artist_name for t in results]
assert names[0] == "Billie Eilish", "the hinted artist's exact match should rank first"
# …but it must NOT filter — the other artists' versions are still there.
assert len(results) == 3
assert {"Aaa Artist", "Bbb Artist"} <= set(names)
def test_exact_title_outranks_superstring_title(db):
# "bad guy" should beat "Bad Guy Necessity" / "Bad Guys" for the query.
_insert(db, 1, "Bad Guy Necessity", 1, "Aardvark") # would sort first alphabetically
_insert(db, 2, "bad guy", 2, "Billie Eilish")
top = db.search_tracks(title="bad guy", limit=5)[0]
assert top.title.lower() == "bad guy" and top.artist_name == "Billie Eilish"

View file

@ -19184,6 +19184,10 @@ def library_search_tracks():
"""Search SoulSync's local database for tracks (for manual match correction)."""
try:
query = request.args.get('q', '').strip()
# Optional source-artist relevance hint (Find & Add knows the artist of
# the track it's matching) — used only to rank exact title+artist matches
# to the top, NOT to filter.
artist_hint = request.args.get('artist', '').strip()
limit = int(request.args.get('limit', 10))
if not query:
return jsonify({"success": True, "tracks": []})
@ -19213,7 +19217,8 @@ def library_search_tracks():
return f"{_art_prefix}{url}{_art_suffix}"
return url
results = database.search_tracks(title=query, artist='', limit=limit, server_source=active_server)
results = database.search_tracks(title=query, artist='', limit=limit,
server_source=active_server, rank_artist=artist_hint)
tracks = []
for t in results:

View file

@ -1680,6 +1680,10 @@ async function serverSearchReplace(trackIndex, mode) {
const searchQuery = src.name ? src.name.trim() : (svr.title || '').trim();
const contextArtist = src.artist || svr.artist || '';
const contextName = src.name || svr.title || '';
// Pass the source artist as a relevance hint so an exact title+artist match
// ranks to the top of the library search instead of being buried under
// same-title tracks by other artists (#: "bad guy" by Billie Eilish).
_serverEditorState.searchArtist = contextArtist;
const existing = document.getElementById('server-search-overlay');
if (existing) existing.remove();
@ -1756,7 +1760,9 @@ async function _serverSearchExecute() {
if (resultsHeader) resultsHeader.textContent = '';
try {
const response = await fetch(`/api/library/search-tracks?q=${encodeURIComponent(query)}&limit=20`);
const artistHint = (_serverEditorState && _serverEditorState.searchArtist) || '';
const response = await fetch(`/api/library/search-tracks?q=${encodeURIComponent(query)}&limit=20`
+ (artistHint ? `&artist=${encodeURIComponent(artistHint)}` : ''));
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {