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).
pull/330/head
JohnBaumb 1 month ago
parent 2f19af779b
commit 6b6fdba3fd

@ -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)

@ -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"""

Loading…
Cancel
Save