perf(smartplaylist): batch-load genres instead of per-row correlated subquery

Evaluate now issues a lean main SELECT over the joined metadata tables
with no genre column, then batch-fetches genres with a single query
using WHERE recording_id IN (...). Previously the track_metadata view's
correlated GROUP_CONCAT subquery ran per row and scaled with library
size rather than result size, producing multi-second load times for
100-track smart playlists.

- Inline the metadata joins instead of using the track_metadata view,
  so the per-row GROUP_CONCAT never runs on the hot path. Other
  callers of the view (search, library listing) are unaffected.
- Route all genre operators (is/is_not/is_any_of/contains/etc.)
  through a recording_genres subquery against af.recording_id.
  Previously text operators like "contains" matched against the
  view's concatenated genre column, which is no longer in scope.
- Sort-by-genre falls back to Go-side sort after the batch genre
  merge since there is no single SQL column to sort on.
- Log main_ms / genres_ms / total_ms at Debug for future tuning.
- Add (*DB).Logger() accessor so smartplaylist can reuse the DB's
  structured logger without changing Evaluate's signature.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-16 11:36:55 -04:00
co-authored by Claude Opus 4.6
parent 5ca16b9c9c
commit ca574a60fe
3 changed files with 360 additions and 116 deletions
+23 -11
View File
@@ -608,7 +608,7 @@ func TestBuildWhereClause_GenreIsAnyOfProducesSubquery(t *testing.T) {
}
}
func TestBuildWhereClause_GenreContainsUsesLIKE(t *testing.T) {
func TestBuildWhereClause_GenreContainsUsesSubquery(t *testing.T) {
t.Parallel()
clause, args, err := BuildWhereClause([]Rule{
@@ -618,17 +618,21 @@ func TestBuildWhereClause_GenreContainsUsesLIKE(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
// contains on genre should use LIKE on the concatenated column,
// NOT a subquery.
if strings.Contains(clause, "recording_genres") {
// Since the smart playlist query no longer projects a concatenated
// genre column, "contains" filters genres via recording_genres
// with g.name LIKE applied to individual genre rows.
if !strings.Contains(clause, "recording_genres") {
t.Errorf(
"genre 'contains' should use LIKE, not subquery: %q",
"genre 'contains' should use recording_genres subquery: %q",
clause,
)
}
if clause != "genre LIKE ?" {
t.Errorf("clause = %q, want %q", clause, "genre LIKE ?")
if !strings.Contains(clause, "g.name LIKE ?") {
t.Errorf(
"genre 'contains' should filter with g.name LIKE ?: %q",
clause,
)
}
if len(args) != 1 || args[0] != "%Rock%" {
@@ -671,10 +675,18 @@ func TestBuildWhereClause_SameFieldMultipleTimes(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
if clause != "genre LIKE ? AND genre NOT LIKE ?" {
t.Errorf("clause = %q, want %q",
clause,
"genre LIKE ? AND genre NOT LIKE ?")
// Genre text ops combine via AND across subqueries against
// recording_genres; the exact SQL shape is asserted elsewhere.
if !strings.Contains(clause, " AND ") {
t.Errorf("clause should combine rules with AND: %q", clause)
}
if !strings.Contains(clause, "af.recording_id IN") {
t.Errorf("clause should include positive IN subquery: %q", clause)
}
if !strings.Contains(clause, "af.recording_id NOT IN") {
t.Errorf("clause should include NOT IN subquery: %q", clause)
}
if len(args) != 2 ||