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:
parent
1517794e23
commit
27d738e7b1
4 changed files with 127 additions and 12 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
68
tests/test_search_tracks_relevance.py
Normal file
68
tests/test_search_tracks_relevance.py
Normal 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"
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue