From 6b6fdba3fddca9f1de50be13eaafefaac7bab402 Mon Sep 17 00:00:00 2001 From: JohnBaumb <80135794+JohnBaumb@users.noreply.github.com> Date: Sun, 19 Apr 2026 14:37:54 -0700 Subject: [PATCH] fix: eliminate double-query in track search The /api/v1/library/tracks endpoint called search_tracks() to get DatabaseTrack objects, then immediately called api_get_tracks_by_ids() to re-hydrate full rows for serialization. Two round trips per search. Added api_search_tracks() that returns dict rows with all track columns plus artist_name, album_title, and album_thumb_url in a single query. The basic and fuzzy search helpers were refactored to share raw-row implementations, so the existing search_tracks() still returns DatabaseTrack objects for the many internal callers that depend on that shape (matching pipeline, repair worker, web UI search). --- api/library.py | 11 +----- database/music_database.py | 81 +++++++++++++++++++++++++------------- 2 files changed, 56 insertions(+), 36 deletions(-) diff --git a/api/library.py b/api/library.py index b5f363c5..5352f971 100644 --- a/api/library.py +++ b/api/library.py @@ -186,15 +186,8 @@ def register_routes(bp): try: db = get_database() - tracks = db.search_tracks(title=title, artist=artist, limit=limit) - if not tracks: - return api_success({"tracks": []}) - - # Re-query by IDs to get full row data - track_ids = [t.id for t in tracks] - full_tracks = db.api_get_tracks_by_ids(track_ids) - - return api_success({"tracks": [serialize_track(t, fields) for t in full_tracks]}) + tracks = db.api_search_tracks(title=title, artist=artist, limit=limit) + return api_success({"tracks": [serialize_track(t, fields) for t in tracks]}) except Exception as e: return api_error("LIBRARY_ERROR", str(e), 500) diff --git a/database/music_database.py b/database/music_database.py index 895367ec..2b650ada 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -5063,12 +5063,41 @@ class MusicDatabase: except Exception as e: logger.error(f"Error searching tracks with title='{title}', artist='{artist}': {e}") return [] + + def api_search_tracks(self, title: str = "", artist: str = "", limit: int = 50, + server_source: Optional[str] = None) -> List[Dict[str, Any]]: + """Search tracks and return full dict rows (all track columns plus artist_name, + album_title, album_thumb_url). Avoids the double-query pattern of calling + search_tracks() followed by api_get_tracks_by_ids(). + """ + try: + if not title and not artist: + return [] + + conn = self._get_connection() + cursor = conn.cursor() + + basic_rows = self._search_tracks_basic_rows(cursor, title, artist, limit, server_source) + if basic_rows: + return [dict(r) for r in basic_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: + 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]: """Basic SQL LIKE search - fastest method""" + rows = self._search_tracks_basic_rows(cursor, title, artist, limit, server_source) + 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).""" where_conditions = [] params = [] - + if title: where_conditions.append("unidecode_lower(tracks.title) LIKE ?") params.append(f"%{self._normalize_for_comparison(title)}%") @@ -5083,13 +5112,13 @@ class MusicDatabase: if server_source: where_conditions.append("tracks.server_source = ?") params.append(server_source) - + if not where_conditions: return [] - + where_clause = " AND ".join(where_conditions) params.append(limit) - + cursor.execute(f""" SELECT tracks.*, artists.name as artist_name, albums.title as album_title, albums.thumb_url as album_thumb_url FROM tracks @@ -5100,45 +5129,47 @@ class MusicDatabase: LIMIT ? """, params) - return self._rows_to_tracks(cursor.fetchall()) + return cursor.fetchall() def _search_tracks_fuzzy_fallback(self, cursor, title: str, artist: str, limit: int, server_source: str = None) -> List[DatabaseTrack]: """Broadest fuzzy search - partial word matching""" + rows = self._search_tracks_fuzzy_rows(cursor, title, artist, limit, server_source) + return self._rows_to_tracks(rows) + + def _search_tracks_fuzzy_rows(self, cursor, title: str, artist: str, limit: int, + server_source: Optional[str] = None): + """Broadest fuzzy search returning raw rows (shared by DatabaseTrack and dict-returning callers).""" # Get broader results by searching for individual words search_terms = [] if title: - # Split title into words and search for each (normalized for diacritics) title_words = [w.strip() for w in self._normalize_for_comparison(title).split() if len(w.strip()) >= 3] search_terms.extend(title_words) if artist: - # Split artist into words and search for each (normalized for diacritics) artist_words = [w.strip() for w in self._normalize_for_comparison(artist).split() if len(w.strip()) >= 3] search_terms.extend(artist_words) - + if not search_terms: return [] - - # Build a query that searches for any of the words + like_conditions = [] params = [] - - for term in search_terms[:5]: # Limit to 5 terms to avoid too broad search + + for term in search_terms[:5]: like_conditions.append("(unidecode_lower(tracks.title) LIKE ? OR unidecode_lower(artists.name) LIKE ? OR unidecode_lower(COALESCE(tracks.track_artist, '')) LIKE ?)") params.extend([f"%{term}%", f"%{term}%", f"%{term}%"]) - + if not like_conditions: return [] - - # Build WHERE clause with optional server filter + where_parts = [f"({' OR '.join(like_conditions)})"] if server_source: where_parts.append("tracks.server_source = ?") - params.append(server_source) # Append after LIKE params, before LIMIT - + params.append(server_source) + where_clause = " AND ".join(where_parts) - params.append(limit * 3) # Get more results for scoring - + params.append(limit * 3) + cursor.execute(f""" SELECT tracks.*, artists.name as artist_name, albums.title as album_title, albums.thumb_url as album_thumb_url FROM tracks @@ -5154,23 +5185,19 @@ class MusicDatabase: # Score and filter results scored_results = [] for row in rows: - # Simple scoring based on how many search terms match score = 0 db_title_lower = self._normalize_for_comparison(row['title']) db_artist_lower = self._normalize_for_comparison(row['artist_name']) - + for term in search_terms: if term in db_title_lower or term in db_artist_lower: score += 1 - + if score > 0: scored_results.append((score, row)) - - # Sort by score and take top results + scored_results.sort(key=lambda x: x[0], reverse=True) - top_rows = [row for score, row in scored_results[:limit]] - - return self._rows_to_tracks(top_rows) + return [row for score, row in scored_results[:limit]] def _rows_to_tracks(self, rows) -> List[DatabaseTrack]: """Convert database rows to DatabaseTrack objects"""