From 27d738e7b1a3fa902b3366f8d2b4ff964cfdae97 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 10 Jun 2026 17:23:13 -0700 Subject: [PATCH] Fix: Find & Add library search buried exact matches (case-sensitive ordering) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- database/music_database.py | 56 ++++++++++++++++++---- tests/test_search_tracks_relevance.py | 68 +++++++++++++++++++++++++++ web_server.py | 7 ++- webui/static/pages-extra.js | 8 +++- 4 files changed, 127 insertions(+), 12 deletions(-) create mode 100644 tests/test_search_tracks_relevance.py diff --git a/database/music_database.py b/database/music_database.py index 7b352a34..3c2ebf86 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -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) diff --git a/tests/test_search_tracks_relevance.py b/tests/test_search_tracks_relevance.py new file mode 100644 index 00000000..7c020356 --- /dev/null +++ b/tests/test_search_tracks_relevance.py @@ -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" diff --git a/web_server.py b/web_server.py index f95f2380..88973d39 100644 --- a/web_server.py +++ b/web_server.py @@ -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: diff --git a/webui/static/pages-extra.js b/webui/static/pages-extra.js index d930bb37..0dd10a46 100644 --- a/webui/static/pages-extra.js +++ b/webui/static/pages-extra.js @@ -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) {