feat(subsonic): sort search3 results by relevance (#5086)

* fix(subsonic): optimize search3 for high-cardinality FTS queries

Use a two-phase query strategy for FTS5 searches to avoid the
performance penalty of expensive LEFT JOINs (annotation, bookmark,
library) on high-cardinality results like "the".

Phase 1 runs a lightweight query (main table + FTS index only) to get
sorted, paginated rowids. Phase 2 hydrates only those few rowids with
the full JOINs, making them nearly free.

For queries with complex ORDER BY expressions that reference joined
tables (e.g. artist search sorted by play count), the optimization is
skipped and the original single-query approach is used.

* fix(search): update order by clauses to include 'rank' for FTS queries

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(search): reintroduce 'rank' in Phase 2 ORDER BY for FTS queries

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(search): remove 'rank' from ORDER BY in non-FTS queries and adjust two-phase query handling

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(search): update FTS ranking to use bm25 weights and simplify ORDER BY qualification

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(search): refine FTS query handling and improve comments for clarity

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(search): refactor full-text search handling to streamline query strategy selection and improve LIKE fallback logic.

Increase e2e coverage for search3

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: enhance FTS column definitions and relevance weights

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(search): refactor Search method signatures to remove offset and size parameters, streamline query handling

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(search): allow single-character queries in search strategies and update related tests

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(search): make FTS Phase 1 treat Max=0 as no limit, reorganize tests

FTS Phase 1 unconditionally called Limit(uint64(options.Max)), which
produced LIMIT 0 when Max was zero. This diverged from applyOptions
where Max=0 means no limit. Now Phase 1 mirrors applyOptions: only add
LIMIT/OFFSET when the value is positive. Also moved legacy backend
integration tests from sql_search_fts_test.go to sql_search_like_test.go
and added regression tests for the Max=0 behavior on both backends.

* refactor: simplify callSearch function by removing variadic options and directly using QueryOptions

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(search): implement ftsQueryDegraded function to detect significant content loss in FTS queries

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan Quintão
2026-02-23 08:51:54 -05:00
committed by GitHub
parent 23bf256a66
commit b59eb32961
23 changed files with 1005 additions and 415 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
package model package model
type SearchableRepository[T any] interface { type SearchableRepository[T any] interface {
Search(q string, offset, size int, options ...QueryOptions) (T, error) Search(q string, options ...QueryOptions) (T, error)
} }
+13 -11
View File
@@ -12,7 +12,6 @@ import (
. "github.com/Masterminds/squirrel" . "github.com/Masterminds/squirrel"
"github.com/deluan/rest" "github.com/deluan/rest"
"github.com/google/uuid"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
@@ -353,18 +352,21 @@ func (r *albumRepository) purgeEmpty(libraryIDs ...int) error {
return nil return nil
} }
func (r *albumRepository) Search(q string, offset int, size int, options ...model.QueryOptions) (model.Albums, error) { var albumSearchConfig = searchConfig{
NaturalOrder: "album.rowid",
OrderBy: []string{"name"},
MBIDFields: []string{"mbz_album_id", "mbz_release_group_id"},
}
func (r *albumRepository) Search(q string, options ...model.QueryOptions) (model.Albums, error) {
var opts model.QueryOptions
if len(options) > 0 {
opts = options[0]
}
var res dbAlbums var res dbAlbums
if uuid.Validate(q) == nil { err := r.doSearch(r.selectAlbum(options...), q, &res, albumSearchConfig, opts)
err := r.searchByMBID(r.selectAlbum(options...), q, []string{"mbz_album_id", "mbz_release_group_id"}, &res)
if err != nil { if err != nil {
return nil, fmt.Errorf("searching album by MBID %q: %w", q, err) return nil, fmt.Errorf("searching album %q: %w", q, err)
}
} else {
err := r.doSearch(r.selectAlbum(options...), q, offset, size, &res, "album.rowid", "name")
if err != nil {
return nil, fmt.Errorf("searching album by query %q: %w", q, err)
}
} }
return res.toModels(), nil return res.toModels(), nil
} }
+17 -13
View File
@@ -11,7 +11,6 @@ import (
. "github.com/Masterminds/squirrel" . "github.com/Masterminds/squirrel"
"github.com/deluan/rest" "github.com/deluan/rest"
"github.com/google/uuid"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
@@ -513,21 +512,26 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) {
return totalRowsAffected, nil return totalRowsAffected, nil
} }
func (r *artistRepository) Search(q string, offset int, size int, options ...model.QueryOptions) (model.Artists, error) { func (r *artistRepository) searchCfg() searchConfig {
var res dbArtists return searchConfig{
if uuid.Validate(q) == nil {
err := r.searchByMBID(r.selectArtist(options...), q, []string{"mbz_artist_id"}, &res)
if err != nil {
return nil, fmt.Errorf("searching artist by MBID %q: %w", q, err)
}
} else {
// Natural order for artists is more performant by ID, due to GROUP BY clause in selectArtist // Natural order for artists is more performant by ID, due to GROUP BY clause in selectArtist
err := r.doSearch(r.selectArtist(options...), q, offset, size, &res, "artist.id", NaturalOrder: "artist.id",
"sum(json_extract(stats, '$.total.m')) desc", "name") OrderBy: []string{"sum(json_extract(stats, '$.total.m')) desc", "name"},
if err != nil { MBIDFields: []string{"mbz_artist_id"},
return nil, fmt.Errorf("searching artist by query %q: %w", q, err) LibraryFilter: r.applyLibraryFilterToArtistQuery,
} }
} }
func (r *artistRepository) Search(q string, options ...model.QueryOptions) (model.Artists, error) {
var opts model.QueryOptions
if len(options) > 0 {
opts = options[0]
}
var res dbArtists
err := r.doSearch(r.selectArtist(options...), q, &res, r.searchCfg(), opts)
if err != nil {
return nil, fmt.Errorf("searching artist %q: %w", q, err)
}
return res.toModels(), nil return res.toModels(), nil
} }
+8 -8
View File
@@ -512,7 +512,7 @@ var _ = Describe("ArtistRepository", func() {
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
// Test the search // Test the search
results, err := (*testRepo).Search("550e8400-e29b-41d4-a716-446655440010", 0, 10) results, err := (*testRepo).Search("550e8400-e29b-41d4-a716-446655440010", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
if shouldFind { if shouldFind {
@@ -543,12 +543,12 @@ var _ = Describe("ArtistRepository", func() {
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
// Restricted user should not find this artist // Restricted user should not find this artist
results, err := restrictedRepo.Search("a74b1b7f-71a5-4011-9441-d0b5e4122711", 0, 10) results, err := restrictedRepo.Search("a74b1b7f-71a5-4011-9441-d0b5e4122711", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(results).To(BeEmpty()) Expect(results).To(BeEmpty())
// But admin should find it // But admin should find it
results, err = repo.Search("a74b1b7f-71a5-4011-9441-d0b5e4122711", 0, 10) results, err = repo.Search("a74b1b7f-71a5-4011-9441-d0b5e4122711", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1)) Expect(results).To(HaveLen(1))
@@ -560,7 +560,7 @@ var _ = Describe("ArtistRepository", func() {
Context("Text Search", func() { Context("Text Search", func() {
It("allows admin to find artists by name regardless of library", func() { It("allows admin to find artists by name regardless of library", func() {
results, err := repo.Search("Beatles", 0, 10) results, err := repo.Search("Beatles", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1)) Expect(results).To(HaveLen(1))
Expect(results[0].Name).To(Equal("The Beatles")) Expect(results[0].Name).To(Equal("The Beatles"))
@@ -580,7 +580,7 @@ var _ = Describe("ArtistRepository", func() {
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
// Restricted user should not find this artist // Restricted user should not find this artist
results, err := restrictedRepo.Search("Unique Search Name", 0, 10) results, err := restrictedRepo.Search("Unique Search Name", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(results).To(BeEmpty(), "Text search should respect library filtering") Expect(results).To(BeEmpty(), "Text search should respect library filtering")
@@ -688,7 +688,7 @@ var _ = Describe("ArtistRepository", func() {
Expect(artists).To(HaveLen(5)) // Including the missing artist Expect(artists).To(HaveLen(5)) // Including the missing artist
// Search never returns missing artists (hardcoded behavior) // Search never returns missing artists (hardcoded behavior)
results, err := repo.Search("Missing Artist", 0, 10) results, err := repo.Search("Missing Artist", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(results).To(BeEmpty()) Expect(results).To(BeEmpty())
}) })
@@ -742,11 +742,11 @@ var _ = Describe("ArtistRepository", func() {
}) })
It("Search returns empty results for users without library access", func() { It("Search returns empty results for users without library access", func() {
results, err := restrictedRepo.Search("Beatles", 0, 10) results, err := restrictedRepo.Search("Beatles", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(results).To(BeEmpty()) Expect(results).To(BeEmpty())
results, err = restrictedRepo.Search("Kraftwerk", 0, 10) results, err = restrictedRepo.Search("Kraftwerk", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(results).To(BeEmpty()) Expect(results).To(BeEmpty())
}) })
+13 -11
View File
@@ -11,7 +11,6 @@ import (
. "github.com/Masterminds/squirrel" . "github.com/Masterminds/squirrel"
"github.com/deluan/rest" "github.com/deluan/rest"
"github.com/google/uuid"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
@@ -428,18 +427,21 @@ func (r *mediaFileRepository) FindRecentFilesByProperties(missing model.MediaFil
return res.toModels(), nil return res.toModels(), nil
} }
func (r *mediaFileRepository) Search(q string, offset int, size int, options ...model.QueryOptions) (model.MediaFiles, error) { var mediaFileSearchConfig = searchConfig{
NaturalOrder: "media_file.rowid",
OrderBy: []string{"title"},
MBIDFields: []string{"mbz_recording_id", "mbz_release_track_id"},
}
func (r *mediaFileRepository) Search(q string, options ...model.QueryOptions) (model.MediaFiles, error) {
var opts model.QueryOptions
if len(options) > 0 {
opts = options[0]
}
var res dbMediaFiles var res dbMediaFiles
if uuid.Validate(q) == nil { err := r.doSearch(r.selectMediaFile(options...), q, &res, mediaFileSearchConfig, opts)
err := r.searchByMBID(r.selectMediaFile(options...), q, []string{"mbz_recording_id", "mbz_release_track_id"}, &res)
if err != nil { if err != nil {
return nil, fmt.Errorf("searching media_file by MBID %q: %w", q, err) return nil, fmt.Errorf("searching media_file %q: %w", q, err)
}
} else {
err := r.doSearch(r.selectMediaFile(options...), q, offset, size, &res, "media_file.rowid", "title")
if err != nil {
return nil, fmt.Errorf("searching media_file by query %q: %w", q, err)
}
} }
return res.toModels(), nil return res.toModels(), nil
} }
+7 -7
View File
@@ -527,7 +527,7 @@ var _ = Describe("MediaRepository", func() {
Describe("Search", func() { Describe("Search", func() {
Context("text search", func() { Context("text search", func() {
It("finds media files by title", func() { It("finds media files by title", func() {
results, err := mr.Search("Antenna", 0, 10) results, err := mr.Search("Antenna", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(3)) // songAntenna, songAntennaWithLyrics, songAntenna2 Expect(results).To(HaveLen(3)) // songAntenna, songAntennaWithLyrics, songAntenna2
for _, result := range results { for _, result := range results {
@@ -536,7 +536,7 @@ var _ = Describe("MediaRepository", func() {
}) })
It("finds media files case insensitively", func() { It("finds media files case insensitively", func() {
results, err := mr.Search("antenna", 0, 10) results, err := mr.Search("antenna", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(3)) Expect(results).To(HaveLen(3))
for _, result := range results { for _, result := range results {
@@ -545,7 +545,7 @@ var _ = Describe("MediaRepository", func() {
}) })
It("returns empty result when no matches found", func() { It("returns empty result when no matches found", func() {
results, err := mr.Search("nonexistent", 0, 10) results, err := mr.Search("nonexistent", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(results).To(BeEmpty()) Expect(results).To(BeEmpty())
}) })
@@ -578,7 +578,7 @@ var _ = Describe("MediaRepository", func() {
}) })
It("finds media file by mbz_recording_id", func() { It("finds media file by mbz_recording_id", func() {
results, err := mr.Search("550e8400-e29b-41d4-a716-446655440020", 0, 10) results, err := mr.Search("550e8400-e29b-41d4-a716-446655440020", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1)) Expect(results).To(HaveLen(1))
Expect(results[0].ID).To(Equal("test-mbid-mediafile")) Expect(results[0].ID).To(Equal("test-mbid-mediafile"))
@@ -586,7 +586,7 @@ var _ = Describe("MediaRepository", func() {
}) })
It("finds media file by mbz_release_track_id", func() { It("finds media file by mbz_release_track_id", func() {
results, err := mr.Search("550e8400-e29b-41d4-a716-446655440021", 0, 10) results, err := mr.Search("550e8400-e29b-41d4-a716-446655440021", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1)) Expect(results).To(HaveLen(1))
Expect(results[0].ID).To(Equal("test-mbid-mediafile")) Expect(results[0].ID).To(Equal("test-mbid-mediafile"))
@@ -594,7 +594,7 @@ var _ = Describe("MediaRepository", func() {
}) })
It("returns empty result when MBID is not found", func() { It("returns empty result when MBID is not found", func() {
results, err := mr.Search("550e8400-e29b-41d4-a716-446655440099", 0, 10) results, err := mr.Search("550e8400-e29b-41d4-a716-446655440099", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(results).To(BeEmpty()) Expect(results).To(BeEmpty())
}) })
@@ -614,7 +614,7 @@ var _ = Describe("MediaRepository", func() {
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
// Search never returns missing media files (hardcoded behavior) // Search never returns missing media files (hardcoded behavior)
results, err := mr.Search("550e8400-e29b-41d4-a716-446655440022", 0, 10) results, err := mr.Search("550e8400-e29b-41d4-a716-446655440022", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(results).To(BeEmpty()) Expect(results).To(BeEmpty())
+2 -4
View File
@@ -109,12 +109,10 @@ func booleanFilter(field string, value any) Sqlizer {
func fullTextFilter(tableName string, mbidFields ...string) func(string, any) Sqlizer { func fullTextFilter(tableName string, mbidFields ...string) func(string, any) Sqlizer {
return func(field string, value any) Sqlizer { return func(field string, value any) Sqlizer {
v := strings.ToLower(value.(string)) v := strings.ToLower(value.(string))
searchExpr := getSearchExpr() return cmp.Or[Sqlizer](
cond := cmp.Or(
mbidExpr(tableName, v, mbidFields...), mbidExpr(tableName, v, mbidFields...),
searchExpr(tableName, v), getSearchStrategy(tableName, v),
) )
return cond
} }
} }
+71 -51
View File
@@ -102,11 +102,11 @@ var _ = Describe("sqlRestful", func() {
uuid := "550e8400-e29b-41d4-a716-446655440000" uuid := "550e8400-e29b-41d4-a716-446655440000"
result := noMbidFilter("search", uuid) result := noMbidFilter("search", uuid)
// mbidExpr with no fields returns nil, so cmp.Or falls back to fullTextExpr // mbidExpr with no fields returns nil, so cmp.Or falls back to search strategy
expected := squirrel.And{ sql, args, err := result.ToSql()
squirrel.Like{"test_table.full_text": "% 550e8400-e29b-41d4-a716-446655440000%"}, Expect(err).ToNot(HaveOccurred())
} Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
Expect(result).To(Equal(expected)) Expect(args).To(ContainElement("% 550e8400-e29b-41d4-a716-446655440000%"))
}) })
}) })
@@ -114,26 +114,25 @@ var _ = Describe("sqlRestful", func() {
It("returns full text search condition only", func() { It("returns full text search condition only", func() {
result := filter("search", "beatles") result := filter("search", "beatles")
// mbidExpr returns nil for non-UUIDs, so fullTextExpr result is returned directly // mbidExpr returns nil for non-UUIDs, so search strategy result is returned directly
expected := squirrel.And{ sql, args, err := result.ToSql()
squirrel.Like{"test_table.full_text": "% beatles%"}, Expect(err).ToNot(HaveOccurred())
} Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
Expect(result).To(Equal(expected)) Expect(args).To(ContainElement("% beatles%"))
}) })
It("handles multi-word search terms", func() { It("handles multi-word search terms", func() {
result := filter("search", "the beatles abbey road") result := filter("search", "the beatles abbey road")
// Should return And condition directly sql, args, err := result.ToSql()
andCondition, ok := result.(squirrel.And) Expect(err).ToNot(HaveOccurred())
Expect(ok).To(BeTrue()) // All words should be present as LIKE conditions
Expect(andCondition).To(HaveLen(4)) Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
Expect(args).To(HaveLen(4))
// Check that all words are present (order may vary) Expect(args).To(ContainElement("% the%"))
Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% the%"})) Expect(args).To(ContainElement("% beatles%"))
Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% beatles%"})) Expect(args).To(ContainElement("% abbey%"))
Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% abbey%"})) Expect(args).To(ContainElement("% road%"))
Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% road%"}))
}) })
}) })
@@ -142,26 +141,48 @@ var _ = Describe("sqlRestful", func() {
conf.Server.Search.FullString = false conf.Server.Search.FullString = false
result := filter("search", "test query") result := filter("search", "test query")
andCondition, ok := result.(squirrel.And) sql, args, err := result.ToSql()
Expect(ok).To(BeTrue()) Expect(err).ToNot(HaveOccurred())
Expect(andCondition).To(HaveLen(2)) Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
Expect(args).To(HaveLen(2))
// Check that all words are present with leading space (order may vary) Expect(args).To(ContainElement("% test%"))
Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% test%"})) Expect(args).To(ContainElement("% query%"))
Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% query%"}))
}) })
It("uses no separator with SearchFullString=true", func() { It("uses no separator with SearchFullString=true", func() {
conf.Server.Search.FullString = true conf.Server.Search.FullString = true
result := filter("search", "test query") result := filter("search", "test query")
andCondition, ok := result.(squirrel.And) sql, args, err := result.ToSql()
Expect(ok).To(BeTrue()) Expect(err).ToNot(HaveOccurred())
Expect(andCondition).To(HaveLen(2)) Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
Expect(args).To(HaveLen(2))
Expect(args).To(ContainElement("%test%"))
Expect(args).To(ContainElement("%query%"))
})
})
// Check that all words are present without leading space (order may vary) Context("single-character queries (regression: must not be rejected)", func() {
Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "%test%"})) It("returns valid filter for single-char query with legacy backend", func() {
Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "%query%"})) conf.Server.Search.Backend = "legacy"
result := filter("search", "a")
Expect(result).ToNot(BeNil(), "single-char REST filter must not be dropped")
sql, args, err := result.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("LIKE"))
Expect(args).ToNot(BeEmpty())
})
It("returns valid filter for single-char query with FTS backend", func() {
conf.Server.Search.Backend = "fts"
conf.Server.Search.FullString = false
ftsFilter := fullTextFilter(tableName, mbidFields...)
result := ftsFilter("search", "a")
Expect(result).ToNot(BeNil(), "single-char REST filter must not be dropped")
sql, args, err := result.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("MATCH"))
Expect(args).ToNot(BeEmpty())
}) })
}) })
@@ -179,10 +200,10 @@ var _ = Describe("sqlRestful", func() {
It("handles special characters that are sanitized", func() { It("handles special characters that are sanitized", func() {
result := filter("search", "don't") result := filter("search", "don't")
expected := squirrel.And{ sql, args, err := result.ToSql()
squirrel.Like{"test_table.full_text": "% dont%"}, // str.SanitizeStrings removes quotes Expect(err).ToNot(HaveOccurred())
} Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
Expect(result).To(Equal(expected)) Expect(args).To(ContainElement("% dont%"))
}) })
It("returns nil for single quote (SQL injection protection)", func() { It("returns nil for single quote (SQL injection protection)", func() {
@@ -206,31 +227,30 @@ var _ = Describe("sqlRestful", func() {
result := filter("search", "550e8400-invalid-uuid") result := filter("search", "550e8400-invalid-uuid")
// Should return full text filter since UUID is invalid // Should return full text filter since UUID is invalid
expected := squirrel.And{ sql, args, err := result.ToSql()
squirrel.Like{"test_table.full_text": "% 550e8400-invalid-uuid%"}, Expect(err).ToNot(HaveOccurred())
} Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
Expect(result).To(Equal(expected)) Expect(args).To(ContainElement("% 550e8400-invalid-uuid%"))
}) })
It("handles empty mbid fields array", func() { It("handles empty mbid fields array", func() {
emptyMbidFilter := fullTextFilter(tableName, []string{}...) emptyMbidFilter := fullTextFilter(tableName, []string{}...)
result := emptyMbidFilter("search", "test") result := emptyMbidFilter("search", "test")
// mbidExpr with empty fields returns nil, so cmp.Or falls back to fullTextExpr // mbidExpr with empty fields returns nil, so search strategy result is returned directly
expected := squirrel.And{ sql, args, err := result.ToSql()
squirrel.Like{"test_table.full_text": "% test%"}, Expect(err).ToNot(HaveOccurred())
} Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
Expect(result).To(Equal(expected)) Expect(args).To(ContainElement("% test%"))
}) })
It("converts value to lowercase before processing", func() { It("converts value to lowercase before processing", func() {
result := filter("search", "TEST") result := filter("search", "TEST")
// The function converts to lowercase internally sql, args, err := result.ToSql()
expected := squirrel.And{ Expect(err).ToNot(HaveOccurred())
squirrel.Like{"test_table.full_text": "% test%"}, Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
} Expect(args).To(ContainElement("% test%"))
Expect(result).To(Equal(expected))
}) })
}) })
}) })
+50 -58
View File
@@ -6,7 +6,6 @@ import (
. "github.com/Masterminds/squirrel" . "github.com/Masterminds/squirrel"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/str" "github.com/navidrome/navidrome/utils/str"
) )
@@ -16,57 +15,71 @@ func formatFullText(text ...string) string {
return " " + fullText return " " + fullText
} }
// searchExprFunc is the function signature for search expression builders. // searchConfig holds per-repository constants for doSearch.
type searchExprFunc func(tableName string, query string) Sqlizer type searchConfig struct {
NaturalOrder string // ORDER BY for empty-query results (e.g. "album.rowid")
OrderBy []string // ORDER BY for text search results (e.g. ["name"])
MBIDFields []string // columns to match when query is a UUID
// LibraryFilter overrides the default applyLibraryFilter for FTS Phase 1.
// Needed when library access requires a junction table (e.g. artist → library_artist).
LibraryFilter func(sq SelectBuilder) SelectBuilder
}
// getSearchExpr returns the active search expression function based on config. // searchStrategy defines how to execute a text search against a repository table.
// It falls back to legacySearchExpr when Search.FullString is enabled, because // options carries filters and pagination that must reach all query phases,
// FTS5 is token-based and cannot match substrings within words. // including FTS Phase 1 which builds its own query outside sq.
// CJK queries are routed to likeSearchExpr, since FTS5's unicode61 tokenizer type searchStrategy interface {
// cannot segment CJK text. Sqlizer
func getSearchExpr() searchExprFunc { execute(r sqlRepository, sq SelectBuilder, dest any, cfg searchConfig, options model.QueryOptions) error
}
// getSearchStrategy returns the appropriate search strategy based on config and query content.
// Returns nil when the query produces no searchable tokens.
func getSearchStrategy(tableName, query string) searchStrategy {
if conf.Server.Search.Backend == "legacy" || conf.Server.Search.FullString { if conf.Server.Search.Backend == "legacy" || conf.Server.Search.FullString {
return legacySearchExpr return newLegacySearch(tableName, query)
} }
return func(tableName, query string) Sqlizer {
if containsCJK(query) { if containsCJK(query) {
return likeSearchExpr(tableName, query) return newLikeSearch(tableName, query)
}
return ftsSearchExpr(tableName, query)
} }
return newFTSSearch(tableName, query)
} }
// doSearch performs a full-text search with the specified parameters. // doSearch dispatches a search query: empty → natural order, UUID → MBID match,
// The naturalOrder is used to sort results when no full-text filter is applied. It is useful for cases like // otherwise delegates to getSearchStrategy. sq must already have LIMIT/OFFSET set
// OpenSubsonic, where an empty search query should return all results in a natural order. Normally the parameter // via newSelect(options...). options is forwarded so FTS Phase 1 can apply the same
// should be `tableName + ".rowid"`, but some repositories (ex: artist) may use a different natural order. // filters and pagination independently.
func (r sqlRepository) doSearch(sq SelectBuilder, q string, offset, size int, results any, naturalOrder string, orderBys ...string) error { func (r sqlRepository) doSearch(sq SelectBuilder, q string, results any, cfg searchConfig, options model.QueryOptions) error {
q = strings.TrimSpace(q) q = strings.TrimSpace(q)
q = strings.TrimSuffix(q, "*") q = strings.TrimSuffix(q, "*")
sq = sq.Where(Eq{r.tableName + ".missing": false})
// Empty query (OpenSubsonic `search3?query=""`) — return all in natural order.
if q == "" || q == `""` {
sq = sq.OrderBy(cfg.NaturalOrder)
return r.queryAll(sq, results, options)
}
// MBID search: if query is a valid UUID, search by MBID fields instead
if uuid.Validate(q) == nil && len(cfg.MBIDFields) > 0 {
sq = sq.Where(mbidExpr(r.tableName, q, cfg.MBIDFields...))
return r.queryAll(sq, results)
}
// Min-length guard: single-character queries are too broad for search3.
// This check lives here (not in the strategies) so that fullTextFilter
// (REST filter path) can still use single-character queries.
if len(q) < 2 { if len(q) < 2 {
return nil return nil
} }
searchExpr := getSearchExpr() strategy := getSearchStrategy(r.tableName, q)
filter := searchExpr(r.tableName, q) if strategy == nil {
if filter != nil { return nil
sq = sq.Where(filter)
sq = sq.OrderBy(orderBys...)
} else {
// This is to speed up the results of `search3?query=""`, for OpenSubsonic
// If the filter is empty, we sort by the specified natural order.
sq = sq.OrderBy(naturalOrder)
}
sq = sq.Where(Eq{r.tableName + ".missing": false})
sq = sq.Limit(uint64(size)).Offset(uint64(offset))
return r.queryAll(sq, results, model.QueryOptions{Offset: offset})
} }
func (r sqlRepository) searchByMBID(sq SelectBuilder, mbid string, mbidFields []string, results any) error { return strategy.execute(r, sq, results, cfg, options)
sq = sq.Where(mbidExpr(r.tableName, mbid, mbidFields...))
sq = sq.Where(Eq{r.tableName + ".missing": false})
return r.queryAll(sq, results)
} }
func mbidExpr(tableName, mbid string, mbidFields ...string) Sqlizer { func mbidExpr(tableName, mbid string, mbidFields ...string) Sqlizer {
@@ -80,24 +93,3 @@ func mbidExpr(tableName, mbid string, mbidFields ...string) Sqlizer {
} }
return Or(cond) return Or(cond)
} }
// legacySearchExpr generates LIKE-based search filters against the full_text column.
// This is the original search implementation, used when Search.Backend="legacy".
func legacySearchExpr(tableName string, s string) Sqlizer {
q := str.SanitizeStrings(s)
if q == "" {
log.Trace("Search using legacy backend, query is empty", "table", tableName)
return nil
}
var sep string
if !conf.Server.Search.FullString {
sep = " "
}
parts := strings.Split(q, " ")
filters := And{}
for _, part := range parts {
filters = append(filters, Like{tableName + ".full_text": "%" + sep + part + "%"})
}
log.Trace("Search using legacy backend", "query", filters, "table", tableName)
return filters
}
+218 -57
View File
@@ -9,6 +9,7 @@ import (
. "github.com/Masterminds/squirrel" . "github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
) )
// containsCJK returns true if the string contains any CJK (Chinese/Japanese/Korean) characters. // containsCJK returns true if the string contains any CJK (Chinese/Japanese/Korean) characters.
@@ -187,75 +188,235 @@ func buildFTS5Query(userInput string) string {
return result return result
} }
// likeSearchColumns defines the core columns to search with LIKE queries. // ftsColumn pairs an FTS5 column name with its BM25 relevance weight.
// These are the primary user-visible fields for each entity type. type ftsColumn struct {
// Used as a fallback when FTS5 cannot handle the query (e.g., CJK text, punctuation-only input). Name string
var likeSearchColumns = map[string][]string{ Weight float64
"media_file": {"title", "album", "artist", "album_artist"},
"album": {"name", "album_artist"},
"artist": {"name"},
} }
// likeSearchExpr generates LIKE-based search filters against core columns. // ftsColumnDefs defines FTS5 columns and their BM25 relevance weights.
// Each word in the query must match at least one column (AND between words), // The order MUST match the column order in the FTS5 table definition (see migrations).
// and each word can match any column (OR within a word). // All columns are both searched and ranked. When adding indexed-but-not-searched
// Used as a fallback when FTS5 cannot handle the query (e.g., CJK text, punctuation-only input). // columns in the future, use Weight: 0 to exclude from the search column filter.
func likeSearchExpr(tableName string, s string) Sqlizer { var ftsColumnDefs = map[string][]ftsColumn{
s = strings.TrimSpace(s) "media_file": {
if s == "" { {"title", 10.0},
log.Trace("Search using LIKE backend, query is empty", "table", tableName) {"album", 5.0},
return nil {"artist", 3.0},
} {"album_artist", 3.0},
columns, ok := likeSearchColumns[tableName] {"sort_title", 1.0},
if !ok { {"sort_album_name", 1.0},
log.Trace("Search using LIKE backend, couldn't find columns for this table", "table", tableName) {"sort_artist_name", 1.0},
return nil {"sort_album_artist_name", 1.0},
} {"disc_subtitle", 1.0},
words := strings.Fields(s) {"search_participants", 2.0},
wordFilters := And{} {"search_normalized", 1.0},
for _, word := range words { },
colFilters := Or{} "album": {
for _, col := range columns { {"name", 10.0},
colFilters = append(colFilters, Like{tableName + "." + col: "%" + word + "%"}) {"sort_album_name", 1.0},
} {"album_artist", 3.0},
wordFilters = append(wordFilters, colFilters) {"search_participants", 2.0},
} {"discs", 1.0},
log.Trace("Search using LIKE backend", "query", wordFilters, "table", tableName) {"catalog_num", 1.0},
return wordFilters {"album_version", 1.0},
{"search_normalized", 1.0},
},
"artist": {
{"name", 10.0},
{"sort_artist_name", 1.0},
{"search_normalized", 1.0},
},
} }
// ftsSearchColumns defines which FTS5 columns are included in general search. // ftsColumnFilters and ftsBM25Weights are precomputed from ftsColumnDefs at init time
// Columns not listed here are indexed but not searched by default, // to avoid per-query allocations.
// enabling future additions (comments, lyrics, bios) without affecting general search. var (
var ftsSearchColumns = map[string]string{ ftsColumnFilters = map[string]string{}
"media_file": "{title album artist album_artist sort_title sort_album_name sort_artist_name sort_album_artist_name disc_subtitle search_participants search_normalized}", ftsBM25Weights = map[string]string{}
"album": "{name sort_album_name album_artist search_participants discs catalog_num album_version search_normalized}", )
"artist": "{name sort_artist_name search_normalized}",
func init() {
for table, cols := range ftsColumnDefs {
var names []string
weights := make([]string, len(cols))
for i, c := range cols {
if c.Weight > 0 {
names = append(names, c.Name)
}
weights[i] = fmt.Sprintf("%.1f", c.Weight)
}
ftsColumnFilters[table] = "{" + strings.Join(names, " ") + "}"
ftsBM25Weights[table] = strings.Join(weights, ", ")
}
} }
// ftsSearchExpr generates an FTS5 MATCH-based search filter. // ftsSearch implements searchStrategy using FTS5 full-text search with BM25 ranking.
// If the query produces no FTS tokens (e.g., punctuation-only like "!!!!!!!"), type ftsSearch struct {
// it falls back to LIKE-based search. tableName string
func ftsSearchExpr(tableName string, s string) Sqlizer { ftsTable string
q := buildFTS5Query(s) matchExpr string
if q == "" { rankExpr string
s = strings.TrimSpace(strings.ReplaceAll(s, `"`, "")) }
if s != "" {
log.Trace("Search using LIKE fallback for non-tokenizable query", "table", tableName, "query", s) // ToSql returns a single-query fallback for the REST filter path (no two-phase split).
return likeSearchExpr(tableName, s) func (s *ftsSearch) ToSql() (string, []interface{}, error) {
sql := s.tableName + ".rowid IN (SELECT rowid FROM " + s.ftsTable + " WHERE " + s.ftsTable + " MATCH ?)"
return sql, []interface{}{s.matchExpr}, nil
}
// execute runs a two-phase FTS5 search:
// - Phase 1: lightweight rowid query (main table + FTS + library filter) for ranking and pagination.
// - Phase 2: full SELECT with all JOINs, scoped to Phase 1's rowid set.
//
// Complex ORDER BY (function calls, aggregations) are dropped from Phase 1.
func (s *ftsSearch) execute(r sqlRepository, sq SelectBuilder, dest any, cfg searchConfig, options model.QueryOptions) error {
qualifiedOrderBys := []string{s.rankExpr}
for _, ob := range cfg.OrderBy {
if qualified := qualifyOrderBy(s.tableName, ob); qualified != "" {
qualifiedOrderBys = append(qualifiedOrderBys, qualified)
}
}
// Phase 1: fresh query — must set LIMIT/OFFSET from options explicitly.
// Mirror applyOptions behavior: Max=0 means no limit, not LIMIT 0.
rowidQuery := Select(s.tableName+".rowid").
From(s.tableName).
Join(s.ftsTable+" ON "+s.ftsTable+".rowid = "+s.tableName+".rowid AND "+s.ftsTable+" MATCH ?", s.matchExpr).
Where(Eq{s.tableName + ".missing": false}).
OrderBy(qualifiedOrderBys...)
if options.Max > 0 {
rowidQuery = rowidQuery.Limit(uint64(options.Max))
}
if options.Offset > 0 {
rowidQuery = rowidQuery.Offset(uint64(options.Offset))
}
// Library filter + musicFolderId must be applied here, before pagination.
if cfg.LibraryFilter != nil {
rowidQuery = cfg.LibraryFilter(rowidQuery)
} else {
rowidQuery = r.applyLibraryFilter(rowidQuery)
}
if options.Filters != nil {
rowidQuery = rowidQuery.Where(options.Filters)
}
rowidSQL, rowidArgs, err := rowidQuery.ToSql()
if err != nil {
return fmt.Errorf("building FTS rowid query: %w", err)
}
// Phase 2: strip LIMIT/OFFSET from sq (Phase 1 handled pagination),
// join on the ranked rowid set to hydrate with full columns.
sq = sq.RemoveLimit().RemoveOffset()
rankedSubquery := fmt.Sprintf(
"(SELECT rowid as _rid, row_number() OVER () AS _rn FROM (%s)) AS _ranked",
rowidSQL,
)
sq = sq.Join(rankedSubquery+" ON "+s.tableName+".rowid = _ranked._rid", rowidArgs...)
sq = sq.OrderBy("_ranked._rn")
return r.queryAll(sq, dest)
}
// qualifyOrderBy prepends tableName to a simple column name. Returns empty string for
// complex expressions (function calls, aggregations) that can't be used in Phase 1.
func qualifyOrderBy(tableName, orderBy string) string {
orderBy = strings.TrimSpace(orderBy)
if orderBy == "" || strings.ContainsAny(orderBy, "(,") {
return ""
}
parts := strings.Fields(orderBy)
if !strings.Contains(parts[0], ".") {
parts[0] = tableName + "." + parts[0]
}
return strings.Join(parts, " ")
}
// ftsQueryDegraded returns true when the FTS query lost significant discriminating
// content compared to the original input. This happens when special characters that
// are part of the entity name (e.g., "1+", "C++", "!!!", "C#") get stripped by FTS
// tokenization, leaving only very short/broad tokens. Also detects quoted phrases
// that would be degraded by FTS5's unicode61 tokenizer (e.g., "1+" → token "1").
func ftsQueryDegraded(original, ftsQuery string) bool {
original = strings.TrimSpace(original)
if original == "" || ftsQuery == "" {
return false
}
// Strip quotes from original for comparison — we want the raw content
stripped := strings.ReplaceAll(original, `"`, "")
// Extract the alphanumeric content from the original query
alphaNum := fts5PunctStrip.ReplaceAllString(stripped, "")
// If the original is entirely alphanumeric, nothing was stripped — not degraded
if len(alphaNum) == len(stripped) {
return false
}
// Check if all effective FTS tokens are very short (≤2 chars).
// Short tokens with prefix matching are too broad when special chars were stripped.
// For quoted phrases, extract the content and check the tokens inside.
tokens := strings.Fields(ftsQuery)
for _, t := range tokens {
t = strings.TrimSuffix(t, "*")
// Skip internal phrase placeholders
if strings.HasPrefix(t, "\x00") {
return false
}
// For OR groups from processPunctuatedWords (e.g., ("a ha" OR aha*)),
// the punctuated word was already handled meaningfully — not degraded.
if strings.HasPrefix(t, "(") {
return false
}
// For quoted phrases, check the tokens inside as FTS5 will tokenize them
if strings.HasPrefix(t, `"`) {
// Extract content between quotes
inner := strings.Trim(t, `"`)
innerAlpha := fts5PunctStrip.ReplaceAllString(inner, " ")
for _, it := range strings.Fields(innerAlpha) {
if len(it) > 2 {
return false
}
}
continue
}
if len(t) > 2 {
return false
}
}
return true
}
// newFTSSearch creates an FTS5 search strategy. Falls back to LIKE search if the
// query produces no FTS tokens (e.g., punctuation-only like "!!!!!!!") or if FTS
// tokenization stripped significant content from the query (e.g., "1+" → "1*").
// Returns nil when the query produces no searchable tokens at all.
func newFTSSearch(tableName, query string) searchStrategy {
q := buildFTS5Query(query)
if q == "" || ftsQueryDegraded(query, q) {
// Fallback: try LIKE search with the raw query
cleaned := strings.TrimSpace(strings.ReplaceAll(query, `"`, ""))
if cleaned != "" {
log.Trace("Search using LIKE fallback for non-tokenizable query", "table", tableName, "query", cleaned)
return newLikeSearch(tableName, cleaned)
} }
return nil return nil
} }
ftsTable := tableName + "_fts" ftsTable := tableName + "_fts"
matchExpr := q matchExpr := q
if cols, ok := ftsSearchColumns[tableName]; ok { if cols, ok := ftsColumnFilters[tableName]; ok {
matchExpr = cols + " : (" + q + ")" matchExpr = cols + " : (" + q + ")"
} }
filter := Expr( rankExpr := ftsTable + ".rank"
tableName+".rowid IN (SELECT rowid FROM "+ftsTable+" WHERE "+ftsTable+" MATCH ?)", if weights, ok := ftsBM25Weights[tableName]; ok {
matchExpr, rankExpr = "bm25(" + ftsTable + ", " + weights + ")"
) }
log.Trace("Search using FTS5 backend", "table", tableName, "query", q, "filter", filter)
return filter s := &ftsSearch{
tableName: tableName,
ftsTable: ftsTable,
matchExpr: matchExpr,
rankExpr: rankExpr,
}
log.Trace("Search using FTS5 backend", "table", tableName, "query", q, "filter", s)
return s
} }
+198 -101
View File
@@ -3,8 +3,6 @@ package persistence
import ( import (
"context" "context"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/model/request"
@@ -52,6 +50,23 @@ var _ = DescribeTable("buildFTS5Query",
Entry("returns empty string for empty quoted phrase", `""`, ""), Entry("returns empty string for empty quoted phrase", `""`, ""),
) )
var _ = DescribeTable("ftsQueryDegraded",
func(original, ftsQuery string, expected bool) {
Expect(ftsQueryDegraded(original, ftsQuery)).To(Equal(expected))
},
Entry("not degraded for empty original", "", "1*", false),
Entry("not degraded for empty ftsQuery", "1+", "", false),
Entry("not degraded for purely alphanumeric query", "beatles", "beatles*", false),
Entry("not degraded when long tokens remain", "test^val", "test* val*", false),
Entry("not degraded for quoted phrase with long tokens", `"the beatles"`, `"the beatles"`, false),
Entry("degraded for quoted phrase with only short tokens after tokenizer strips special chars", `"1+"`, `"1+"`, true),
Entry("not degraded for quoted phrase with meaningful content", `"C++ programming"`, `"C++ programming"`, false),
Entry("degraded when special chars stripped leaving short token", "1+", "1*", true),
Entry("degraded when special chars stripped leaving two short tokens", "C# 1", "C* 1*", true),
Entry("not degraded when at least one long token remains", "1+ beatles", "1* beatles*", false),
Entry("not degraded for OR groups from processPunctuatedWords", "AC/DC", `("AC DC" OR ACDC*)`, false),
)
var _ = DescribeTable("normalizeForFTS", var _ = DescribeTable("normalizeForFTS",
func(expected string, values ...string) { func(expected string, values ...string) {
Expect(normalizeForFTS(values...)).To(Equal(expected)) Expect(normalizeForFTS(values...)).To(Equal(expected))
@@ -81,133 +96,211 @@ var _ = DescribeTable("containsCJK",
Entry("detects single CJK character", "a曲b", true), Entry("detects single CJK character", "a曲b", true),
) )
var _ = Describe("likeSearchExpr", func() { var _ = DescribeTable("qualifyOrderBy",
It("returns nil for empty query", func() { func(tableName, orderBy, expected string) {
Expect(likeSearchExpr("media_file", "")).To(BeNil()) Expect(qualifyOrderBy(tableName, orderBy)).To(Equal(expected))
},
Entry("returns empty string for empty input", "artist", "", ""),
Entry("qualifies simple column with table name", "artist", "name", "artist.name"),
Entry("qualifies column with direction", "artist", "name desc", "artist.name desc"),
Entry("preserves already-qualified column", "artist", "artist.name", "artist.name"),
Entry("preserves already-qualified column with direction", "artist", "artist.name desc", "artist.name desc"),
Entry("returns empty for function call expression", "artist", "sum(json_extract(stats, '$.total.m')) desc", ""),
Entry("returns empty for expression with comma", "artist", "a, b", ""),
Entry("qualifies media_file column", "media_file", "title", "media_file.title"),
)
var _ = Describe("ftsColumnDefs helpers", func() {
Describe("ftsColumnFilters", func() {
It("returns column filter for media_file", func() {
Expect(ftsColumnFilters).To(HaveKeyWithValue("media_file",
"{title album artist album_artist sort_title sort_album_name sort_artist_name sort_album_artist_name disc_subtitle search_participants search_normalized}",
))
}) })
It("returns nil for whitespace-only query", func() { It("returns column filter for album", func() {
Expect(likeSearchExpr("media_file", " ")).To(BeNil()) Expect(ftsColumnFilters).To(HaveKeyWithValue("album",
"{name sort_album_name album_artist search_participants discs catalog_num album_version search_normalized}",
))
}) })
It("generates LIKE filters against core columns for single CJK word", func() { It("returns column filter for artist", func() {
expr := likeSearchExpr("media_file", "周杰伦") Expect(ftsColumnFilters).To(HaveKeyWithValue("artist",
sql, args, err := expr.ToSql() "{name sort_artist_name search_normalized}",
Expect(err).ToNot(HaveOccurred()) ))
// Should have OR between columns for the single word })
Expect(sql).To(ContainSubstring("OR"))
Expect(sql).To(ContainSubstring("media_file.title LIKE")) It("has no entry for unknown table", func() {
Expect(sql).To(ContainSubstring("media_file.album LIKE")) Expect(ftsColumnFilters).ToNot(HaveKey("unknown"))
Expect(sql).To(ContainSubstring("media_file.artist LIKE")) })
Expect(sql).To(ContainSubstring("media_file.album_artist LIKE")) })
Expect(args).To(HaveLen(4))
for _, arg := range args { Describe("ftsBM25Weights", func() {
Expect(arg).To(Equal("%周杰伦%")) It("returns weight CSV for media_file", func() {
Expect(ftsBM25Weights).To(HaveKeyWithValue("media_file",
"10.0, 5.0, 3.0, 3.0, 1.0, 1.0, 1.0, 1.0, 1.0, 2.0, 1.0",
))
})
It("returns weight CSV for album", func() {
Expect(ftsBM25Weights).To(HaveKeyWithValue("album",
"10.0, 1.0, 3.0, 2.0, 1.0, 1.0, 1.0, 1.0",
))
})
It("returns weight CSV for artist", func() {
Expect(ftsBM25Weights).To(HaveKeyWithValue("artist",
"10.0, 1.0, 1.0",
))
})
It("has no entry for unknown table", func() {
Expect(ftsBM25Weights).ToNot(HaveKey("unknown"))
})
})
It("has definitions for all known tables", func() {
for _, table := range []string{"media_file", "album", "artist"} {
Expect(ftsColumnDefs).To(HaveKey(table))
Expect(ftsColumnDefs[table]).ToNot(BeEmpty())
} }
}) })
It("generates AND of OR groups for multi-word query", func() { It("has matching column count between filter and weights", func() {
expr := likeSearchExpr("media_file", "周杰伦 greatest") for table, cols := range ftsColumnDefs {
sql, args, err := expr.ToSql() // Column filter only includes Weight > 0 columns
Expect(err).ToNot(HaveOccurred()) filterCount := 0
// Two groups AND'd together, each with 4 columns OR'd for _, c := range cols {
Expect(sql).To(ContainSubstring("AND")) if c.Weight > 0 {
Expect(args).To(HaveLen(8)) filterCount++
}) }
}
It("uses correct columns for album table", func() { // For now, all columns have Weight > 0, so filter count == total count
expr := likeSearchExpr("album", "周杰伦") Expect(filterCount).To(Equal(len(cols)), "table %s: all columns should have positive weights", table)
sql, args, err := expr.ToSql() }
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("album.name LIKE"))
Expect(sql).To(ContainSubstring("album.album_artist LIKE"))
Expect(args).To(HaveLen(2))
})
It("uses correct columns for artist table", func() {
expr := likeSearchExpr("artist", "周杰伦")
sql, args, err := expr.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("artist.name LIKE"))
Expect(args).To(HaveLen(1))
})
It("returns nil for unknown table", func() {
Expect(likeSearchExpr("unknown_table", "周杰伦")).To(BeNil())
}) })
}) })
var _ = Describe("ftsSearchExpr", func() { var _ = Describe("newFTSSearch", func() {
It("returns nil for empty query", func() { It("returns nil for empty query", func() {
Expect(ftsSearchExpr("media_file", "")).To(BeNil()) Expect(newFTSSearch("media_file", "")).To(BeNil())
}) })
It("generates rowid IN subquery with MATCH and column filter", func() { It("returns non-nil for single-character query", func() {
expr := ftsSearchExpr("media_file", "beatles") strategy := newFTSSearch("media_file", "a")
sql, args, err := expr.ToSql() Expect(strategy).ToNot(BeNil(), "single-char queries must not be rejected; min-length is enforced in doSearch, not here")
sql, _, err := strategy.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("MATCH"))
})
It("returns ftsSearch with correct table names and MATCH expression", func() {
strategy := newFTSSearch("media_file", "beatles")
fts, ok := strategy.(*ftsSearch)
Expect(ok).To(BeTrue())
Expect(fts.tableName).To(Equal("media_file"))
Expect(fts.ftsTable).To(Equal("media_file_fts"))
Expect(fts.matchExpr).To(HavePrefix("{title album artist album_artist"))
Expect(fts.matchExpr).To(ContainSubstring("beatles*"))
})
It("ToSql generates rowid IN subquery with MATCH (fallback path)", func() {
strategy := newFTSSearch("media_file", "beatles")
sql, args, err := strategy.ToSql()
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("media_file.rowid IN")) Expect(sql).To(ContainSubstring("media_file.rowid IN"))
Expect(sql).To(ContainSubstring("media_file_fts")) Expect(sql).To(ContainSubstring("media_file_fts"))
Expect(sql).To(ContainSubstring("MATCH")) Expect(sql).To(ContainSubstring("MATCH"))
Expect(args).To(HaveLen(1)) Expect(args).To(HaveLen(1))
Expect(args[0]).To(HavePrefix("{title album artist album_artist"))
Expect(args[0]).To(ContainSubstring("beatles*"))
}) })
It("generates correct FTS table name per entity", func() { It("generates correct FTS table name per entity", func() {
for _, table := range []string{"media_file", "album", "artist"} { for _, table := range []string{"media_file", "album", "artist"} {
expr := ftsSearchExpr(table, "test") strategy := newFTSSearch(table, "test")
sql, _, err := expr.ToSql() fts, ok := strategy.(*ftsSearch)
Expect(err).ToNot(HaveOccurred()) Expect(ok).To(BeTrue())
Expect(sql).To(ContainSubstring(table + ".rowid IN")) Expect(fts.tableName).To(Equal(table))
Expect(sql).To(ContainSubstring(table + "_fts")) Expect(fts.ftsTable).To(Equal(table + "_fts"))
} }
}) })
It("builds bm25() rank expression with column weights", func() {
strategy := newFTSSearch("media_file", "beatles")
fts, ok := strategy.(*ftsSearch)
Expect(ok).To(BeTrue())
Expect(fts.rankExpr).To(HavePrefix("bm25(media_file_fts,"))
Expect(fts.rankExpr).To(ContainSubstring("10.0"))
strategy = newFTSSearch("artist", "beatles")
fts, ok = strategy.(*ftsSearch)
Expect(ok).To(BeTrue())
Expect(fts.rankExpr).To(HavePrefix("bm25(artist_fts,"))
})
It("falls back to ftsTable.rank for unknown tables", func() {
strategy := newFTSSearch("unknown_table", "test")
fts, ok := strategy.(*ftsSearch)
Expect(ok).To(BeTrue())
Expect(fts.rankExpr).To(Equal("unknown_table_fts.rank"))
})
It("wraps query with column filter for known tables", func() { It("wraps query with column filter for known tables", func() {
expr := ftsSearchExpr("artist", "Beatles") strategy := newFTSSearch("artist", "Beatles")
_, args, err := expr.ToSql() fts, ok := strategy.(*ftsSearch)
Expect(err).ToNot(HaveOccurred()) Expect(ok).To(BeTrue())
Expect(args[0]).To(Equal("{name sort_artist_name search_normalized} : (Beatles*)")) Expect(fts.matchExpr).To(Equal("{name sort_artist_name search_normalized} : (Beatles*)"))
}) })
It("passes query without column filter for unknown tables", func() { It("passes query without column filter for unknown tables", func() {
expr := ftsSearchExpr("unknown_table", "test") strategy := newFTSSearch("unknown_table", "test")
_, args, err := expr.ToSql() fts, ok := strategy.(*ftsSearch)
Expect(err).ToNot(HaveOccurred()) Expect(ok).To(BeTrue())
Expect(args[0]).To(Equal("test*")) Expect(fts.matchExpr).To(Equal("test*"))
}) })
It("preserves phrase queries inside column filter", func() { It("preserves phrase queries inside column filter", func() {
expr := ftsSearchExpr("media_file", `"the beatles"`) strategy := newFTSSearch("media_file", `"the beatles"`)
_, args, err := expr.ToSql() fts, ok := strategy.(*ftsSearch)
Expect(err).ToNot(HaveOccurred()) Expect(ok).To(BeTrue())
Expect(args[0]).To(ContainSubstring(`"the beatles"`)) Expect(fts.matchExpr).To(ContainSubstring(`"the beatles"`))
}) })
It("preserves prefix queries inside column filter", func() { It("preserves prefix queries inside column filter", func() {
expr := ftsSearchExpr("media_file", "beat*") strategy := newFTSSearch("media_file", "beat*")
_, args, err := expr.ToSql() fts, ok := strategy.(*ftsSearch)
Expect(err).ToNot(HaveOccurred()) Expect(ok).To(BeTrue())
Expect(args[0]).To(ContainSubstring("beat*")) Expect(fts.matchExpr).To(ContainSubstring("beat*"))
}) })
It("falls back to LIKE search for punctuation-only query", func() { It("falls back to LIKE search for punctuation-only query", func() {
expr := ftsSearchExpr("media_file", "!!!!!!!") strategy := newFTSSearch("media_file", "!!!!!!!")
Expect(expr).ToNot(BeNil()) Expect(strategy).ToNot(BeNil())
sql, args, err := expr.ToSql() _, ok := strategy.(*ftsSearch)
Expect(ok).To(BeFalse(), "punctuation-only should fall back to LIKE, not FTS")
sql, args, err := strategy.ToSql()
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("LIKE")) Expect(sql).To(ContainSubstring("LIKE"))
Expect(args).To(ContainElement("%!!!!!!!%")) Expect(args).To(ContainElement("%!!!!!!!%"))
}) })
It("falls back to LIKE search for degraded query (special chars stripped leaving short tokens)", func() {
strategy := newFTSSearch("album", "1+")
Expect(strategy).ToNot(BeNil())
_, ok := strategy.(*ftsSearch)
Expect(ok).To(BeFalse(), "degraded query should fall back to LIKE, not FTS")
sql, args, err := strategy.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("LIKE"))
Expect(args).To(ContainElement("%1+%"))
})
It("returns nil for empty string even with LIKE fallback", func() { It("returns nil for empty string even with LIKE fallback", func() {
Expect(ftsSearchExpr("media_file", "")).To(BeNil()) Expect(newFTSSearch("media_file", "")).To(BeNil())
Expect(ftsSearchExpr("media_file", " ")).To(BeNil()) Expect(newFTSSearch("media_file", " ")).To(BeNil())
}) })
It("returns nil for empty quoted phrase", func() { It("returns nil for empty quoted phrase", func() {
Expect(ftsSearchExpr("media_file", `""`)).To(BeNil()) Expect(newFTSSearch("media_file", `""`)).To(BeNil())
}) })
}) })
@@ -229,7 +322,7 @@ var _ = Describe("FTS5 Integration Search", func() {
Describe("MediaFile search", func() { Describe("MediaFile search", func() {
It("finds media files by title", func() { It("finds media files by title", func() {
results, err := mr.Search("Radioactivity", 0, 10) results, err := mr.Search("Radioactivity", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1)) Expect(results).To(HaveLen(1))
Expect(results[0].Title).To(Equal("Radioactivity")) Expect(results[0].Title).To(Equal("Radioactivity"))
@@ -237,7 +330,7 @@ var _ = Describe("FTS5 Integration Search", func() {
}) })
It("finds media files by artist name", func() { It("finds media files by artist name", func() {
results, err := mr.Search("Beatles", 0, 10) results, err := mr.Search("Beatles", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(3)) Expect(results).To(HaveLen(3))
for _, r := range results { for _, r := range results {
@@ -248,7 +341,7 @@ var _ = Describe("FTS5 Integration Search", func() {
Describe("Album search", func() { Describe("Album search", func() {
It("finds albums by name", func() { It("finds albums by name", func() {
results, err := alr.Search("Sgt Peppers", 0, 10) results, err := alr.Search("Sgt Peppers", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1)) Expect(results).To(HaveLen(1))
Expect(results[0].Name).To(Equal("Sgt Peppers")) Expect(results[0].Name).To(Equal("Sgt Peppers"))
@@ -256,7 +349,7 @@ var _ = Describe("FTS5 Integration Search", func() {
}) })
It("finds albums with multi-word search", func() { It("finds albums with multi-word search", func() {
results, err := alr.Search("Abbey Road", 0, 10) results, err := alr.Search("Abbey Road", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(2)) Expect(results).To(HaveLen(2))
}) })
@@ -264,7 +357,7 @@ var _ = Describe("FTS5 Integration Search", func() {
Describe("Artist search", func() { Describe("Artist search", func() {
It("finds artists by name", func() { It("finds artists by name", func() {
results, err := arr.Search("Kraftwerk", 0, 10) results, err := arr.Search("Kraftwerk", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1)) Expect(results).To(HaveLen(1))
Expect(results[0].Name).To(Equal("Kraftwerk")) Expect(results[0].Name).To(Equal("Kraftwerk"))
@@ -274,7 +367,7 @@ var _ = Describe("FTS5 Integration Search", func() {
Describe("CJK search", func() { Describe("CJK search", func() {
It("finds media files by CJK title", func() { It("finds media files by CJK title", func() {
results, err := mr.Search("プラチナ", 0, 10) results, err := mr.Search("プラチナ", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1)) Expect(results).To(HaveLen(1))
Expect(results[0].Title).To(Equal("プラチナ・ジェット")) Expect(results[0].Title).To(Equal("プラチナ・ジェット"))
@@ -282,14 +375,14 @@ var _ = Describe("FTS5 Integration Search", func() {
}) })
It("finds media files by CJK artist name", func() { It("finds media files by CJK artist name", func() {
results, err := mr.Search("シートベルツ", 0, 10) results, err := mr.Search("シートベルツ", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1)) Expect(results).To(HaveLen(1))
Expect(results[0].Artist).To(Equal("シートベルツ")) Expect(results[0].Artist).To(Equal("シートベルツ"))
}) })
It("finds albums by CJK artist name", func() { It("finds albums by CJK artist name", func() {
results, err := alr.Search("シートベルツ", 0, 10) results, err := alr.Search("シートベルツ", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1)) Expect(results).To(HaveLen(1))
Expect(results[0].Name).To(Equal("COWBOY BEBOP")) Expect(results[0].Name).To(Equal("COWBOY BEBOP"))
@@ -297,7 +390,7 @@ var _ = Describe("FTS5 Integration Search", func() {
}) })
It("finds artists by CJK name", func() { It("finds artists by CJK name", func() {
results, err := arr.Search("シートベルツ", 0, 10) results, err := arr.Search("シートベルツ", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1)) Expect(results).To(HaveLen(1))
Expect(results[0].Name).To(Equal("シートベルツ")) Expect(results[0].Name).To(Equal("シートベルツ"))
@@ -307,7 +400,7 @@ var _ = Describe("FTS5 Integration Search", func() {
Describe("Album version search", func() { Describe("Album version search", func() {
It("finds albums by version tag via FTS", func() { It("finds albums by version tag via FTS", func() {
results, err := alr.Search("Deluxe", 0, 10) results, err := alr.Search("Deluxe", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1)) Expect(results).To(HaveLen(1))
Expect(results[0].ID).To(Equal(albumWithVersion.ID)) Expect(results[0].ID).To(Equal(albumWithVersion.ID))
@@ -316,7 +409,7 @@ var _ = Describe("FTS5 Integration Search", func() {
Describe("Punctuation-only search", func() { Describe("Punctuation-only search", func() {
It("finds media files with punctuation-only title", func() { It("finds media files with punctuation-only title", func() {
results, err := mr.Search("!!!!!!!", 0, 10) results, err := mr.Search("!!!!!!!", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1)) Expect(results).To(HaveLen(1))
Expect(results[0].Title).To(Equal("!!!!!!!")) Expect(results[0].Title).To(Equal("!!!!!!!"))
@@ -324,15 +417,19 @@ var _ = Describe("FTS5 Integration Search", func() {
}) })
}) })
Describe("Legacy backend fallback", func() { Describe("Single-character search (doSearch min-length guard)", func() {
It("returns results using legacy LIKE-based search when configured", func() { It("returns empty results for single-char query via Search", func() {
DeferCleanup(configtest.SetupConfig()) results, err := mr.Search("a", model.QueryOptions{Max: 10})
conf.Server.Search.Backend = "legacy"
results, err := mr.Search("Radioactivity", 0, 10)
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1)) Expect(results).To(BeEmpty(), "doSearch should reject single-char queries")
Expect(results[0].Title).To(Equal("Radioactivity")) })
})
Describe("Max=0 means no limit (regression: must not produce LIMIT 0)", func() {
It("returns results with Max=0", func() {
results, err := mr.Search("Beatles", model.QueryOptions{Max: 0})
Expect(err).ToNot(HaveOccurred())
Expect(results).ToNot(BeEmpty(), "Max=0 should mean no limit, not LIMIT 0")
}) })
}) })
}) })
+106
View File
@@ -0,0 +1,106 @@
package persistence
import (
"strings"
. "github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/str"
)
// likeSearch implements searchStrategy using LIKE-based SQL filters.
// Used for legacy full_text searches, CJK fallback, and punctuation-only fallback.
type likeSearch struct {
filter Sqlizer
}
func (s *likeSearch) ToSql() (string, []interface{}, error) {
return s.filter.ToSql()
}
func (s *likeSearch) execute(r sqlRepository, sq SelectBuilder, dest any, cfg searchConfig, options model.QueryOptions) error {
sq = sq.Where(s.filter)
sq = sq.OrderBy(cfg.OrderBy...)
return r.queryAll(sq, dest, options)
}
// newLegacySearch creates a LIKE search against the full_text column.
// Returns nil when the query produces no searchable tokens.
func newLegacySearch(tableName, query string) searchStrategy {
filter := legacySearchExpr(tableName, query)
if filter == nil {
return nil
}
return &likeSearch{filter: filter}
}
// newLikeSearch creates a LIKE search against core entity columns (CJK, punctuation fallback).
// No minimum length is enforced, since single CJK characters are meaningful words.
// Returns nil when the query produces no searchable tokens.
func newLikeSearch(tableName, query string) searchStrategy {
filter := likeSearchExpr(tableName, query)
if filter == nil {
return nil
}
return &likeSearch{filter: filter}
}
// legacySearchExpr generates LIKE-based search filters against the full_text column.
// This is the original search implementation, used when Search.Backend="legacy".
func legacySearchExpr(tableName string, s string) Sqlizer {
q := str.SanitizeStrings(s)
if q == "" {
log.Trace("Search using legacy backend, query is empty", "table", tableName)
return nil
}
var sep string
if !conf.Server.Search.FullString {
sep = " "
}
parts := strings.Split(q, " ")
filters := And{}
for _, part := range parts {
filters = append(filters, Like{tableName + ".full_text": "%" + sep + part + "%"})
}
log.Trace("Search using legacy backend", "query", filters, "table", tableName)
return filters
}
// likeSearchColumns defines the core columns to search with LIKE queries.
// These are the primary user-visible fields for each entity type.
// Used as a fallback when FTS5 cannot handle the query (e.g., CJK text, punctuation-only input).
var likeSearchColumns = map[string][]string{
"media_file": {"title", "album", "artist", "album_artist"},
"album": {"name", "album_artist"},
"artist": {"name"},
}
// likeSearchExpr generates LIKE-based search filters against core columns.
// Each word in the query must match at least one column (AND between words),
// and each word can match any column (OR within a word).
// Used as a fallback when FTS5 cannot handle the query (e.g., CJK text, punctuation-only input).
func likeSearchExpr(tableName string, s string) Sqlizer {
s = strings.TrimSpace(s)
if s == "" {
log.Trace("Search using LIKE backend, query is empty", "table", tableName)
return nil
}
columns, ok := likeSearchColumns[tableName]
if !ok {
log.Trace("Search using LIKE backend, couldn't find columns for this table", "table", tableName)
return nil
}
words := strings.Fields(s)
wordFilters := And{}
for _, word := range words {
colFilters := Or{}
for _, col := range columns {
colFilters = append(colFilters, Like{tableName + "." + col: "%" + word + "%"})
}
wordFilters = append(wordFilters, colFilters)
}
log.Trace("Search using LIKE backend", "query", wordFilters, "table", tableName)
return wordFilters
}
+134
View File
@@ -0,0 +1,134 @@
package persistence
import (
"context"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("newLegacySearch", func() {
It("returns non-nil for single-character query", func() {
strategy := newLegacySearch("media_file", "a")
Expect(strategy).ToNot(BeNil(), "single-char queries must not be rejected; min-length is enforced in doSearch, not here")
sql, _, err := strategy.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("LIKE"))
})
})
var _ = Describe("legacySearchExpr", func() {
It("returns nil for empty query", func() {
Expect(legacySearchExpr("media_file", "")).To(BeNil())
})
It("generates LIKE filter for single word", func() {
expr := legacySearchExpr("media_file", "beatles")
sql, args, err := expr.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("media_file.full_text LIKE"))
Expect(args).To(ContainElement("% beatles%"))
})
It("generates AND of LIKE filters for multiple words", func() {
expr := legacySearchExpr("media_file", "abbey road")
sql, args, err := expr.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("AND"))
Expect(args).To(HaveLen(2))
})
})
var _ = Describe("likeSearchExpr", func() {
It("returns nil for empty query", func() {
Expect(likeSearchExpr("media_file", "")).To(BeNil())
})
It("returns nil for whitespace-only query", func() {
Expect(likeSearchExpr("media_file", " ")).To(BeNil())
})
It("generates LIKE filters against core columns for single CJK word", func() {
expr := likeSearchExpr("media_file", "周杰伦")
sql, args, err := expr.ToSql()
Expect(err).ToNot(HaveOccurred())
// Should have OR between columns for the single word
Expect(sql).To(ContainSubstring("OR"))
Expect(sql).To(ContainSubstring("media_file.title LIKE"))
Expect(sql).To(ContainSubstring("media_file.album LIKE"))
Expect(sql).To(ContainSubstring("media_file.artist LIKE"))
Expect(sql).To(ContainSubstring("media_file.album_artist LIKE"))
Expect(args).To(HaveLen(4))
for _, arg := range args {
Expect(arg).To(Equal("%周杰伦%"))
}
})
It("generates AND of OR groups for multi-word query", func() {
expr := likeSearchExpr("media_file", "周杰伦 greatest")
sql, args, err := expr.ToSql()
Expect(err).ToNot(HaveOccurred())
// Two groups AND'd together, each with 4 columns OR'd
Expect(sql).To(ContainSubstring("AND"))
Expect(args).To(HaveLen(8))
})
It("uses correct columns for album table", func() {
expr := likeSearchExpr("album", "周杰伦")
sql, args, err := expr.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("album.name LIKE"))
Expect(sql).To(ContainSubstring("album.album_artist LIKE"))
Expect(args).To(HaveLen(2))
})
It("uses correct columns for artist table", func() {
expr := likeSearchExpr("artist", "周杰伦")
sql, args, err := expr.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("artist.name LIKE"))
Expect(args).To(HaveLen(1))
})
It("returns nil for unknown table", func() {
Expect(likeSearchExpr("unknown_table", "周杰伦")).To(BeNil())
})
})
var _ = Describe("Legacy Integration Search", func() {
var mr model.MediaFileRepository
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.Search.Backend = "legacy"
ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, adminUser)
conn := GetDBXBuilder()
mr = NewMediaFileRepository(ctx, conn)
})
It("returns results using legacy LIKE-based search", func() {
results, err := mr.Search("Radioactivity", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1))
Expect(results[0].Title).To(Equal("Radioactivity"))
})
It("returns empty results for single-char query (doSearch min-length guard)", func() {
results, err := mr.Search("a", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(BeEmpty(), "doSearch should reject single-char queries")
})
It("returns results with Max=0 (regression: must not produce LIMIT 0)", func() {
results, err := mr.Search("Beatles", model.QueryOptions{Max: 0})
Expect(err).ToNot(HaveOccurred())
Expect(results).ToNot(BeEmpty(), "Max=0 should mean no limit, not LIMIT 0")
})
})
+42 -41
View File
@@ -14,98 +14,99 @@ var _ = Describe("sqlRepository", func() {
}) })
}) })
Describe("legacySearchExpr", func() { Describe("getSearchStrategy", func() {
It("returns nil for empty query", func() { It("returns FTS strategy by default", func() {
Expect(legacySearchExpr("media_file", "")).To(BeNil())
})
It("generates LIKE filter for single word", func() {
expr := legacySearchExpr("media_file", "beatles")
sql, args, err := expr.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("media_file.full_text LIKE"))
Expect(args).To(ContainElement("% beatles%"))
})
It("generates AND of LIKE filters for multiple words", func() {
expr := legacySearchExpr("media_file", "abbey road")
sql, args, err := expr.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("AND"))
Expect(args).To(HaveLen(2))
})
})
Describe("getSearchExpr", func() {
It("returns ftsSearchExpr by default", func() {
DeferCleanup(configtest.SetupConfig()) DeferCleanup(configtest.SetupConfig())
conf.Server.Search.Backend = "fts" conf.Server.Search.Backend = "fts"
conf.Server.Search.FullString = false conf.Server.Search.FullString = false
expr := getSearchExpr()("media_file", "test") strategy := getSearchStrategy("media_file", "test")
sql, _, err := expr.ToSql() Expect(strategy).ToNot(BeNil())
sql, _, err := strategy.ToSql()
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("MATCH")) Expect(sql).To(ContainSubstring("MATCH"))
}) })
It("returns legacySearchExpr when SearchBackend is legacy", func() { It("returns legacy LIKE strategy when SearchBackend is legacy", func() {
DeferCleanup(configtest.SetupConfig()) DeferCleanup(configtest.SetupConfig())
conf.Server.Search.Backend = "legacy" conf.Server.Search.Backend = "legacy"
conf.Server.Search.FullString = false conf.Server.Search.FullString = false
expr := getSearchExpr()("media_file", "test") strategy := getSearchStrategy("media_file", "test")
sql, _, err := expr.ToSql() Expect(strategy).ToNot(BeNil())
sql, _, err := strategy.ToSql()
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("LIKE")) Expect(sql).To(ContainSubstring("LIKE"))
}) })
It("falls back to legacySearchExpr when SearchFullString is enabled", func() { It("falls back to legacy LIKE strategy when SearchFullString is enabled", func() {
DeferCleanup(configtest.SetupConfig()) DeferCleanup(configtest.SetupConfig())
conf.Server.Search.Backend = "fts" conf.Server.Search.Backend = "fts"
conf.Server.Search.FullString = true conf.Server.Search.FullString = true
expr := getSearchExpr()("media_file", "test") strategy := getSearchStrategy("media_file", "test")
sql, _, err := expr.ToSql() Expect(strategy).ToNot(BeNil())
sql, _, err := strategy.ToSql()
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("LIKE")) Expect(sql).To(ContainSubstring("LIKE"))
}) })
It("routes CJK queries to likeSearchExpr instead of ftsSearchExpr", func() { It("routes CJK queries to LIKE strategy instead of FTS", func() {
DeferCleanup(configtest.SetupConfig()) DeferCleanup(configtest.SetupConfig())
conf.Server.Search.Backend = "fts" conf.Server.Search.Backend = "fts"
conf.Server.Search.FullString = false conf.Server.Search.FullString = false
expr := getSearchExpr()("media_file", "周杰伦") strategy := getSearchStrategy("media_file", "周杰伦")
sql, _, err := expr.ToSql() Expect(strategy).ToNot(BeNil())
sql, _, err := strategy.ToSql()
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
// CJK should use LIKE, not MATCH // CJK should use LIKE, not MATCH
Expect(sql).To(ContainSubstring("LIKE")) Expect(sql).To(ContainSubstring("LIKE"))
Expect(sql).NotTo(ContainSubstring("MATCH")) Expect(sql).NotTo(ContainSubstring("MATCH"))
}) })
It("routes non-CJK queries to ftsSearchExpr", func() { It("routes non-CJK queries to FTS strategy", func() {
DeferCleanup(configtest.SetupConfig()) DeferCleanup(configtest.SetupConfig())
conf.Server.Search.Backend = "fts" conf.Server.Search.Backend = "fts"
conf.Server.Search.FullString = false conf.Server.Search.FullString = false
expr := getSearchExpr()("media_file", "beatles") strategy := getSearchStrategy("media_file", "beatles")
sql, _, err := expr.ToSql() Expect(strategy).ToNot(BeNil())
sql, _, err := strategy.ToSql()
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("MATCH")) Expect(sql).To(ContainSubstring("MATCH"))
}) })
It("returns non-nil for single-character query (no min-length in strategy)", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.Search.Backend = "fts"
conf.Server.Search.FullString = false
strategy := getSearchStrategy("media_file", "a")
Expect(strategy).ToNot(BeNil(), "single-char queries must be accepted by strategies (min-length is enforced in doSearch)")
})
It("returns non-nil for single-character query with legacy backend", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.Search.Backend = "legacy"
conf.Server.Search.FullString = false
strategy := getSearchStrategy("media_file", "a")
Expect(strategy).ToNot(BeNil(), "single-char queries must be accepted by legacy strategy (min-length is enforced in doSearch)")
})
It("uses legacy for CJK when SearchBackend is legacy", func() { It("uses legacy for CJK when SearchBackend is legacy", func() {
DeferCleanup(configtest.SetupConfig()) DeferCleanup(configtest.SetupConfig())
conf.Server.Search.Backend = "legacy" conf.Server.Search.Backend = "legacy"
conf.Server.Search.FullString = false conf.Server.Search.FullString = false
expr := getSearchExpr()("media_file", "周杰伦") strategy := getSearchStrategy("media_file", "周杰伦")
sql, _, err := expr.ToSql() Expect(strategy).ToNot(BeNil())
sql, _, err := strategy.ToSql()
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
// Legacy should still use full_text column LIKE // Legacy should still use full_text column LIKE
Expect(sql).To(ContainSubstring("LIKE")) Expect(sql).To(ContainSubstring("LIKE"))
Expect(sql).To(ContainSubstring("full_text")) Expect(sql).To(ContainSubstring("full_text"))
}) })
}) })
}) })
+3
View File
@@ -115,6 +115,7 @@ func buildTestFS() storagetest.FakeFS {
ledZepIV := template(_t{"albumartist": "Led Zeppelin", "artist": "Led Zeppelin", "album": "IV", "year": 1971, "genre": "Rock"}) ledZepIV := template(_t{"albumartist": "Led Zeppelin", "artist": "Led Zeppelin", "album": "IV", "year": 1971, "genre": "Rock"})
kindOfBlue := template(_t{"albumartist": "Miles Davis", "artist": "Miles Davis", "album": "Kind of Blue", "year": 1959, "genre": "Jazz"}) kindOfBlue := template(_t{"albumartist": "Miles Davis", "artist": "Miles Davis", "album": "Kind of Blue", "year": 1959, "genre": "Jazz"})
popTrack := template(_t{"albumartist": "Various", "artist": "Various", "album": "Pop", "year": 2020, "genre": "Pop"}) popTrack := template(_t{"albumartist": "Various", "artist": "Various", "album": "Pop", "year": 2020, "genre": "Pop"})
cowboyBebop := template(_t{"albumartist": "シートベルツ", "artist": "シートベルツ", "album": "COWBOY BEBOP", "year": 1998, "genre": "Jazz"})
return createFS(fstest.MapFS{ return createFS(fstest.MapFS{
// Rock / The Beatles / Abbey Road (with MBIDs) // Rock / The Beatles / Abbey Road (with MBIDs)
@@ -132,6 +133,8 @@ func buildTestFS() storagetest.FakeFS {
"Jazz/Miles Davis/Kind of Blue/01 - So What.mp3": kindOfBlue(track(1, "So What")), "Jazz/Miles Davis/Kind of Blue/01 - So What.mp3": kindOfBlue(track(1, "So What")),
// Pop (standalone track, no MBIDs) // Pop (standalone track, no MBIDs)
"Pop/01 - Standalone Track.mp3": popTrack(track(1, "Standalone Track")), "Pop/01 - Standalone Track.mp3": popTrack(track(1, "Standalone Track")),
// CJK / シートベルツ / COWBOY BEBOP (Japanese artist, for CJK search tests)
"CJK/シートベルツ/COWBOY BEBOP/01 - プラチナ・ジェット.mp3": cowboyBebop(track(1, "プラチナ・ジェット")),
// _empty folder (directory with no audio) // _empty folder (directory with no audio)
"_empty/.keep": &fstest.MapFile{Data: []byte{}, ModTime: time.Now()}, "_empty/.keep": &fstest.MapFile{Data: []byte{}, ModTime: time.Now()},
}) })
+23 -17
View File
@@ -19,7 +19,7 @@ var _ = Describe("Album List Endpoints", func() {
Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.AlbumList).ToNot(BeNil()) Expect(resp.AlbumList).ToNot(BeNil())
Expect(resp.AlbumList.Album).To(HaveLen(5)) Expect(resp.AlbumList.Album).To(HaveLen(6))
}) })
It("type=alphabeticalByName sorts albums by name", func() { It("type=alphabeticalByName sorts albums by name", func() {
@@ -27,13 +27,14 @@ var _ = Describe("Album List Endpoints", func() {
Expect(resp.AlbumList).ToNot(BeNil()) Expect(resp.AlbumList).ToNot(BeNil())
albums := resp.AlbumList.Album albums := resp.AlbumList.Album
Expect(albums).To(HaveLen(5)) Expect(albums).To(HaveLen(6))
// Verify alphabetical order: Abbey Road, Help!, IV, Kind of Blue, Pop // Verify alphabetical order: Abbey Road, COWBOY BEBOP, Help!, IV, Kind of Blue, Pop
Expect(albums[0].Title).To(Equal("Abbey Road")) Expect(albums[0].Title).To(Equal("Abbey Road"))
Expect(albums[1].Title).To(Equal("Help!")) Expect(albums[1].Title).To(Equal("COWBOY BEBOP"))
Expect(albums[2].Title).To(Equal("IV")) Expect(albums[2].Title).To(Equal("Help!"))
Expect(albums[3].Title).To(Equal("Kind of Blue")) Expect(albums[3].Title).To(Equal("IV"))
Expect(albums[4].Title).To(Equal("Pop")) Expect(albums[4].Title).To(Equal("Kind of Blue"))
Expect(albums[5].Title).To(Equal("Pop"))
}) })
It("type=alphabeticalByArtist sorts albums by artist name", func() { It("type=alphabeticalByArtist sorts albums by artist name", func() {
@@ -41,29 +42,32 @@ var _ = Describe("Album List Endpoints", func() {
Expect(resp.AlbumList).ToNot(BeNil()) Expect(resp.AlbumList).ToNot(BeNil())
albums := resp.AlbumList.Album albums := resp.AlbumList.Album
Expect(albums).To(HaveLen(5)) Expect(albums).To(HaveLen(6))
// Articles like "The" are stripped for sorting, so "The Beatles" sorts as "Beatles" // Articles like "The" are stripped for sorting, so "The Beatles" sorts as "Beatles"
// Non-compilations first: Beatles (x2), Led Zeppelin, Miles Davis, then compilations: Various // Non-compilations first: Beatles (x2), Led Zeppelin, Miles Davis, then compilations: Various, then CJK: シートベルツ
Expect(albums[0].Artist).To(Equal("The Beatles")) Expect(albums[0].Artist).To(Equal("The Beatles"))
Expect(albums[1].Artist).To(Equal("The Beatles")) Expect(albums[1].Artist).To(Equal("The Beatles"))
Expect(albums[2].Artist).To(Equal("Led Zeppelin")) Expect(albums[2].Artist).To(Equal("Led Zeppelin"))
Expect(albums[3].Artist).To(Equal("Miles Davis")) Expect(albums[3].Artist).To(Equal("Miles Davis"))
Expect(albums[4].Artist).To(Equal("Various")) Expect(albums[4].Artist).To(Equal("Various"))
Expect(albums[5].Artist).To(Equal("シートベルツ"))
}) })
It("type=random returns albums", func() { It("type=random returns albums", func() {
resp := doReq("getAlbumList", "type", "random") resp := doReq("getAlbumList", "type", "random")
Expect(resp.AlbumList).ToNot(BeNil()) Expect(resp.AlbumList).ToNot(BeNil())
Expect(resp.AlbumList.Album).To(HaveLen(5)) Expect(resp.AlbumList.Album).To(HaveLen(6))
}) })
It("type=byGenre filters by genre parameter", func() { It("type=byGenre filters by genre parameter", func() {
resp := doReq("getAlbumList", "type", "byGenre", "genre", "Jazz") resp := doReq("getAlbumList", "type", "byGenre", "genre", "Jazz")
Expect(resp.AlbumList).ToNot(BeNil()) Expect(resp.AlbumList).ToNot(BeNil())
Expect(resp.AlbumList.Album).To(HaveLen(1)) Expect(resp.AlbumList.Album).To(HaveLen(2))
Expect(resp.AlbumList.Album[0].Title).To(Equal("Kind of Blue")) for _, a := range resp.AlbumList.Album {
Expect(a.Genre).To(Equal("Jazz"))
}
}) })
It("type=byYear filters by fromYear/toYear range", func() { It("type=byYear filters by fromYear/toYear range", func() {
@@ -184,7 +188,7 @@ var _ = Describe("Album List Endpoints", func() {
Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.AlbumList2).ToNot(BeNil()) Expect(resp.AlbumList2).ToNot(BeNil())
albums := resp.AlbumList2.Album albums := resp.AlbumList2.Album
Expect(albums).To(HaveLen(5)) Expect(albums).To(HaveLen(6))
// Verify AlbumID3 format fields // Verify AlbumID3 format fields
Expect(albums[0].Name).To(Equal("Abbey Road")) Expect(albums[0].Name).To(Equal("Abbey Road"))
Expect(albums[0].Id).ToNot(BeEmpty()) Expect(albums[0].Id).ToNot(BeEmpty())
@@ -195,7 +199,7 @@ var _ = Describe("Album List Endpoints", func() {
resp := doReq("getAlbumList2", "type", "newest") resp := doReq("getAlbumList2", "type", "newest")
Expect(resp.AlbumList2).ToNot(BeNil()) Expect(resp.AlbumList2).ToNot(BeNil())
Expect(resp.AlbumList2.Album).To(HaveLen(5)) Expect(resp.AlbumList2.Album).To(HaveLen(6))
}) })
}) })
@@ -240,7 +244,7 @@ var _ = Describe("Album List Endpoints", func() {
Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.RandomSongs).ToNot(BeNil()) Expect(resp.RandomSongs).ToNot(BeNil())
Expect(resp.RandomSongs.Songs).ToNot(BeEmpty()) Expect(resp.RandomSongs.Songs).ToNot(BeEmpty())
Expect(len(resp.RandomSongs.Songs)).To(BeNumerically("<=", 6)) Expect(resp.RandomSongs.Songs).To(HaveLen(7))
}) })
It("respects size parameter", func() { It("respects size parameter", func() {
@@ -254,8 +258,10 @@ var _ = Describe("Album List Endpoints", func() {
resp := doReq("getRandomSongs", "size", "500", "genre", "Jazz") resp := doReq("getRandomSongs", "size", "500", "genre", "Jazz")
Expect(resp.RandomSongs).ToNot(BeNil()) Expect(resp.RandomSongs).ToNot(BeNil())
Expect(resp.RandomSongs.Songs).To(HaveLen(1)) Expect(resp.RandomSongs.Songs).To(HaveLen(2))
Expect(resp.RandomSongs.Songs[0].Genre).To(Equal("Jazz")) for _, s := range resp.RandomSongs.Songs {
Expect(s.Genre).To(Equal("Jazz"))
}
}) })
}) })
+2 -2
View File
@@ -327,8 +327,8 @@ var _ = Describe("Browsing Endpoints", func() {
} }
} }
Expect(jazzGenre).ToNot(BeNil()) Expect(jazzGenre).ToNot(BeNil())
Expect(jazzGenre.SongCount).To(Equal(int32(1))) Expect(jazzGenre.SongCount).To(Equal(int32(2)))
Expect(jazzGenre.AlbumCount).To(Equal(int32(1))) Expect(jazzGenre.AlbumCount).To(Equal(int32(2)))
}) })
It("reports correct song and album counts for Pop", func() { It("reports correct song and album counts for Pop", func() {
+17 -1
View File
@@ -141,7 +141,7 @@ var _ = Describe("Multi-Library Support", Ordered, func() {
resp := doReqWithUser(adminWithLibs, "getAlbumList", "type", "alphabeticalByName", "musicFolderId", fmt.Sprintf("%d", lib.ID)) resp := doReqWithUser(adminWithLibs, "getAlbumList", "type", "alphabeticalByName", "musicFolderId", fmt.Sprintf("%d", lib.ID))
Expect(resp.AlbumList).ToNot(BeNil()) Expect(resp.AlbumList).ToNot(BeNil())
Expect(resp.AlbumList.Album).To(HaveLen(5)) Expect(resp.AlbumList.Album).To(HaveLen(6))
for _, a := range resp.AlbumList.Album { for _, a := range resp.AlbumList.Album {
Expect(a.Title).ToNot(Equal("Symphony No. 9")) Expect(a.Title).ToNot(Equal("Symphony No. 9"))
} }
@@ -275,5 +275,21 @@ var _ = Describe("Multi-Library Support", Ordered, func() {
Expect(artistNames).To(ContainElements("The Beatles", "Led Zeppelin", "Miles Davis")) Expect(artistNames).To(ContainElements("The Beatles", "Led Zeppelin", "Miles Davis"))
Expect(artistNames).ToNot(ContainElement("Ludwig van Beethoven")) Expect(artistNames).ToNot(ContainElement("Ludwig van Beethoven"))
}) })
It("non-admin user search returns only their library's content", func() {
resp := doReqWithUser(userLib1Only, "search3", "query", "Beethoven")
Expect(resp.SearchResult3).ToNot(BeNil())
Expect(resp.SearchResult3.Artist).To(BeEmpty(), "userLib1Only should not see Beethoven (lib2)")
Expect(resp.SearchResult3.Album).To(BeEmpty())
Expect(resp.SearchResult3.Song).To(BeEmpty())
})
It("non-admin user search finds content from their library", func() {
resp := doReqWithUser(userLib1Only, "search3", "query", "Beatles")
Expect(resp.SearchResult3).ToNot(BeNil())
Expect(resp.SearchResult3.Artist).ToNot(BeEmpty(), "userLib1Only should find Beatles (lib1)")
})
}) })
}) })
+56 -3
View File
@@ -2,6 +2,8 @@ package e2e
import ( import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/server/subsonic/responses"
. "github.com/onsi/ginkgo/v2" . "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
@@ -113,9 +115,9 @@ var _ = Describe("Search Endpoints", func() {
Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.SearchResult3).ToNot(BeNil()) Expect(resp.SearchResult3).ToNot(BeNil())
Expect(resp.SearchResult3.Artist).To(HaveLen(4)) Expect(resp.SearchResult3.Artist).To(HaveLen(5))
Expect(resp.SearchResult3.Album).To(HaveLen(5)) Expect(resp.SearchResult3.Album).To(HaveLen(6))
Expect(resp.SearchResult3.Song).To(HaveLen(6)) Expect(resp.SearchResult3.Song).To(HaveLen(7))
}) })
It("finds across all entity types simultaneously", func() { It("finds across all entity types simultaneously", func() {
@@ -217,5 +219,56 @@ var _ = Describe("Search Endpoints", func() {
Expect(resp.SearchResult3.Song).To(BeEmpty()) Expect(resp.SearchResult3.Song).To(BeEmpty())
}) })
}) })
Describe("CJK search", func() {
It("finds songs by CJK title", func() {
resp := doReq("search3", "query", "プラチナ")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.SearchResult3).ToNot(BeNil())
Expect(resp.SearchResult3.Song).To(HaveLen(1))
Expect(resp.SearchResult3.Song[0].Title).To(Equal("プラチナ・ジェット"))
})
It("finds artists by CJK name", func() {
resp := doReq("search3", "query", "シートベルツ")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.SearchResult3).ToNot(BeNil())
Expect(resp.SearchResult3.Artist).To(HaveLen(1))
Expect(resp.SearchResult3.Artist[0].Name).To(Equal("シートベルツ"))
})
It("finds albums by CJK artist name", func() {
resp := doReq("search3", "query", "シートベルツ")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.SearchResult3).ToNot(BeNil())
Expect(resp.SearchResult3.Album).To(HaveLen(1))
Expect(resp.SearchResult3.Album[0].Name).To(Equal("COWBOY BEBOP"))
})
})
Describe("Legacy backend", func() {
It("returns results using legacy LIKE-based search when configured", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.Search.Backend = "legacy"
resp := doReq("search3", "query", "Beatles")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.SearchResult3).ToNot(BeNil())
Expect(resp.SearchResult3.Artist).ToNot(BeEmpty())
found := false
for _, a := range resp.SearchResult3.Artist {
if a.Name == "The Beatles" {
found = true
break
}
}
Expect(found).To(BeTrue(), "expected to find artist 'The Beatles' with legacy backend")
})
})
}) })
}) })
+15 -20
View File
@@ -42,17 +42,17 @@ func (api *Router) getSearchParams(r *http.Request) (*searchParams, error) {
return sp, nil return sp, nil
} }
type searchFunc[T any] func(q string, offset int, size int, options ...model.QueryOptions) (T, error) type searchFunc[T any] func(q string, options ...model.QueryOptions) (T, error)
func callSearch[T any](ctx context.Context, s searchFunc[T], q string, offset, size int, result *T, options ...model.QueryOptions) func() error { func callSearch[T any](ctx context.Context, s searchFunc[T], q string, options model.QueryOptions, result *T) func() error {
return func() error { return func() error {
if size == 0 { if options.Max == 0 {
return nil return nil
} }
typ := strings.TrimPrefix(reflect.TypeOf(*result).String(), "model.") typ := strings.TrimPrefix(reflect.TypeOf(*result).String(), "model.")
var err error var err error
start := time.Now() start := time.Now()
*result, err = s(q, offset, size, options...) *result, err = s(q, options)
if err != nil { if err != nil {
log.Error(ctx, "Error searching "+typ, "query", q, "elapsed", time.Since(start), err) log.Error(ctx, "Error searching "+typ, "query", q, "elapsed", time.Since(start), err)
} else { } else {
@@ -66,27 +66,22 @@ func (api *Router) searchAll(ctx context.Context, sp *searchParams, musicFolderI
start := time.Now() start := time.Now()
q := sanitize.Accents(strings.ToLower(strings.TrimSuffix(sp.query, "*"))) q := sanitize.Accents(strings.ToLower(strings.TrimSuffix(sp.query, "*")))
// Create query options for library filtering // Build options with offset/size/filters packed in
var options []model.QueryOptions songOpts := model.QueryOptions{Max: sp.songCount, Offset: sp.songOffset}
var artistOptions []model.QueryOptions albumOpts := model.QueryOptions{Max: sp.albumCount, Offset: sp.albumOffset}
artistOpts := model.QueryOptions{Max: sp.artistCount, Offset: sp.artistOffset}
if len(musicFolderIds) > 0 { if len(musicFolderIds) > 0 {
// For MediaFiles and Albums, use direct library_id filter songOpts.Filters = Eq{"library_id": musicFolderIds}
options = append(options, model.QueryOptions{ albumOpts.Filters = Eq{"library_id": musicFolderIds}
Filters: Eq{"library_id": musicFolderIds}, artistOpts.Filters = Eq{"library_artist.library_id": musicFolderIds}
})
// For Artists, use the repository's built-in library filtering mechanism
// which properly handles the library_artist table joins
// TODO Revisit library filtering in sql_base_repository.go
artistOptions = append(artistOptions, model.QueryOptions{
Filters: Eq{"library_artist.library_id": musicFolderIds},
})
} }
// Run searches in parallel // Run searches in parallel
g, ctx := errgroup.WithContext(ctx) g, ctx := errgroup.WithContext(ctx)
g.Go(callSearch(ctx, api.ds.MediaFile(ctx).Search, q, sp.songOffset, sp.songCount, &mediaFiles, options...)) g.Go(callSearch(ctx, api.ds.MediaFile(ctx).Search, q, songOpts, &mediaFiles))
g.Go(callSearch(ctx, api.ds.Album(ctx).Search, q, sp.albumOffset, sp.albumCount, &albums, options...)) g.Go(callSearch(ctx, api.ds.Album(ctx).Search, q, albumOpts, &albums))
g.Go(callSearch(ctx, api.ds.Artist(ctx).Search, q, sp.artistOffset, sp.artistCount, &artists, artistOptions...)) g.Go(callSearch(ctx, api.ds.Artist(ctx).Search, q, artistOpts, &artists))
err := g.Wait() err := g.Wait()
if err == nil { if err == nil {
log.Debug(ctx, fmt.Sprintf("Search resulted in %d songs, %d albums and %d artists", log.Debug(ctx, fmt.Sprintf("Search resulted in %d songs, %d albums and %d artists",
+1 -1
View File
@@ -119,7 +119,7 @@ func (m *MockAlbumRepo) UpdateExternalInfo(album *model.Album) error {
return nil return nil
} }
func (m *MockAlbumRepo) Search(q string, offset int, size int, options ...model.QueryOptions) (model.Albums, error) { func (m *MockAlbumRepo) Search(q string, options ...model.QueryOptions) (model.Albums, error) {
if len(options) > 0 { if len(options) > 0 {
m.Options = options[0] m.Options = options[0]
} }
+1 -1
View File
@@ -145,7 +145,7 @@ func (m *MockArtistRepo) GetIndex(includeMissing bool, libraryIds []int, roles .
return result, nil return result, nil
} }
func (m *MockArtistRepo) Search(q string, offset int, size int, options ...model.QueryOptions) (model.Artists, error) { func (m *MockArtistRepo) Search(q string, options ...model.QueryOptions) (model.Artists, error) {
if len(options) > 0 { if len(options) > 0 {
m.Options = options[0] m.Options = options[0]
} }
+1 -1
View File
@@ -238,7 +238,7 @@ func (m *MockMediaFileRepo) NewInstance() any {
return &model.MediaFile{} return &model.MediaFile{}
} }
func (m *MockMediaFileRepo) Search(q string, offset int, size int, options ...model.QueryOptions) (model.MediaFiles, error) { func (m *MockMediaFileRepo) Search(q string, options ...model.QueryOptions) (model.MediaFiles, error) {
if len(options) > 0 { if len(options) > 0 {
m.Options = options[0] m.Options = options[0]
} }