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,8 +6631,13 @@ class MusicDatabase:
|
||||||
logger.error(f"Error searching artists with query '{query}': {e}")
|
logger.error(f"Error searching artists with query '{query}': {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def search_tracks(self, title: str = "", artist: str = "", limit: int = 50, server_source: str = None) -> List[DatabaseTrack]:
|
def search_tracks(self, title: str = "", artist: str = "", limit: int = 50, server_source: str = None,
|
||||||
"""Search tracks by title and/or artist name with Unicode-aware fuzzy matching"""
|
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:
|
try:
|
||||||
if not title and not artist:
|
if not title and not artist:
|
||||||
return []
|
return []
|
||||||
|
|
@ -6641,7 +6646,7 @@ class MusicDatabase:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
# STRATEGY 1: Try basic SQL LIKE search first (fastest)
|
# 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:
|
if basic_results:
|
||||||
logger.debug(f"Basic search found {len(basic_results)} 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}")
|
logger.error(f"API: Error searching tracks with title='{title}', artist='{artist}': {e}")
|
||||||
return []
|
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"""
|
"""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)
|
return self._rows_to_tracks(rows)
|
||||||
|
|
||||||
def _search_tracks_basic_rows(self, cursor, title: str, artist: str, limit: int,
|
def _search_tracks_basic_rows(self, cursor, title: str, artist: str, limit: int,
|
||||||
server_source: Optional[str] = None):
|
server_source: Optional[str] = None, rank_artist: Optional[str] = None):
|
||||||
"""Basic SQL LIKE search returning raw rows (shared by DatabaseTrack and dict-returning callers)."""
|
"""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 = []
|
where_conditions = []
|
||||||
params = []
|
params = []
|
||||||
|
|
||||||
|
|
@ -6711,6 +6720,33 @@ class MusicDatabase:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
where_clause = " AND ".join(where_conditions)
|
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)
|
params.append(limit)
|
||||||
|
|
||||||
cursor.execute(f"""
|
cursor.execute(f"""
|
||||||
|
|
@ -6719,7 +6755,7 @@ class MusicDatabase:
|
||||||
JOIN artists ON tracks.artist_id = artists.id
|
JOIN artists ON tracks.artist_id = artists.id
|
||||||
JOIN albums ON tracks.album_id = albums.id
|
JOIN albums ON tracks.album_id = albums.id
|
||||||
WHERE {where_clause}
|
WHERE {where_clause}
|
||||||
ORDER BY tracks.title, artists.name
|
ORDER BY {order_by}
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
""", params)
|
""", 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)."""
|
"""Search SoulSync's local database for tracks (for manual match correction)."""
|
||||||
try:
|
try:
|
||||||
query = request.args.get('q', '').strip()
|
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))
|
limit = int(request.args.get('limit', 10))
|
||||||
if not query:
|
if not query:
|
||||||
return jsonify({"success": True, "tracks": []})
|
return jsonify({"success": True, "tracks": []})
|
||||||
|
|
@ -19213,7 +19217,8 @@ def library_search_tracks():
|
||||||
return f"{_art_prefix}{url}{_art_suffix}"
|
return f"{_art_prefix}{url}{_art_suffix}"
|
||||||
return url
|
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 = []
|
tracks = []
|
||||||
for t in results:
|
for t in results:
|
||||||
|
|
|
||||||
|
|
@ -1680,6 +1680,10 @@ async function serverSearchReplace(trackIndex, mode) {
|
||||||
const searchQuery = src.name ? src.name.trim() : (svr.title || '').trim();
|
const searchQuery = src.name ? src.name.trim() : (svr.title || '').trim();
|
||||||
const contextArtist = src.artist || svr.artist || '';
|
const contextArtist = src.artist || svr.artist || '';
|
||||||
const contextName = src.name || svr.title || '';
|
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');
|
const existing = document.getElementById('server-search-overlay');
|
||||||
if (existing) existing.remove();
|
if (existing) existing.remove();
|
||||||
|
|
@ -1756,7 +1760,9 @@ async function _serverSearchExecute() {
|
||||||
if (resultsHeader) resultsHeader.textContent = '';
|
if (resultsHeader) resultsHeader.textContent = '';
|
||||||
|
|
||||||
try {
|
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();
|
const data = await response.json();
|
||||||
|
|
||||||
if (!data.success || !data.tracks || data.tracks.length === 0) {
|
if (!data.success || !data.tracks || data.tracks.length === 0) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue