diff --git a/db/migrations/20250701010107_add_mbid_indexes.sql b/db/migrations/20250701010107_add_mbid_indexes.sql new file mode 100644 index 000000000..f8a5a444b --- /dev/null +++ b/db/migrations/20250701010107_add_mbid_indexes.sql @@ -0,0 +1,27 @@ +-- +goose Up +-- +goose StatementBegin + +-- Add indexes for MBID fields to improve lookup performance +-- Artists table +create index if not exists artist_mbz_artist_id + on artist (mbz_artist_id); + +-- Albums table +create index if not exists album_mbz_album_id + on album (mbz_album_id); + +-- Media files table +create index if not exists media_file_mbz_release_track_id + on media_file (mbz_release_track_id); + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin + +-- Remove MBID indexes +drop index if exists artist_mbz_artist_id; +drop index if exists album_mbz_album_id; +drop index if exists media_file_mbz_release_track_id; + +-- +goose StatementEnd diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 3f238ee23..08bc80039 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -12,6 +12,7 @@ import ( . "github.com/Masterminds/squirrel" "github.com/deluan/rest" + "github.com/google/uuid" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -112,7 +113,7 @@ func NewAlbumRepository(ctx context.Context, db dbx.Builder) model.AlbumReposito var albumFilters = sync.OnceValue(func() map[string]filterFunc { filters := map[string]filterFunc{ "id": idFilter("album"), - "name": fullTextFilter("album"), + "name": fullTextFilter("album", "mbz_album_id", "mbz_release_group_id"), "compilation": booleanFilter, "artist_id": artistFilter, "year": yearFilter, @@ -347,11 +348,18 @@ func (r *albumRepository) purgeEmpty() error { func (r *albumRepository) Search(q string, offset int, size int, includeMissing bool) (model.Albums, error) { var res dbAlbums - err := r.doSearch(r.selectAlbum(), q, offset, size, includeMissing, &res, "name") - if err != nil { - return nil, err + if uuid.Validate(q) == nil { + err := r.searchByMBID(r.selectAlbum(), q, []string{"mbz_album_id", "mbz_release_group_id"}, includeMissing, &res) + if err != nil { + return nil, fmt.Errorf("searching album by MBID %q: %w", q, err) + } + } else { + err := r.doSearch(r.selectAlbum(), q, offset, size, includeMissing, &res, "name") + if err != nil { + return nil, fmt.Errorf("searching album by query %q: %w", q, err) + } } - return res.toModels(), err + return res.toModels(), nil } func (r *albumRepository) Count(options ...rest.QueryOptions) (int64, error) { diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index 977f0cb8b..f5b892ba1 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -11,6 +11,7 @@ import ( . "github.com/Masterminds/squirrel" "github.com/deluan/rest" + "github.com/google/uuid" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -113,7 +114,7 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi r.tableName = "artist" // To be used by the idFilter below r.registerModel(&model.Artist{}, map[string]filterFunc{ "id": idFilter(r.tableName), - "name": fullTextFilter(r.tableName), + "name": fullTextFilter(r.tableName, "mbz_artist_id"), "starred": booleanFilter, "role": roleFilter, "missing": booleanFilter, @@ -433,12 +434,19 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) { } func (r *artistRepository) Search(q string, offset int, size int, includeMissing bool) (model.Artists, error) { - var dba dbArtists - err := r.doSearch(r.selectArtist(), q, offset, size, includeMissing, &dba, "json_extract(stats, '$.total.m') desc", "name") - if err != nil { - return nil, err + var res dbArtists + if uuid.Validate(q) == nil { + err := r.searchByMBID(r.selectArtist(), q, []string{"mbz_artist_id"}, includeMissing, &res) + if err != nil { + return nil, fmt.Errorf("searching artist by MBID %q: %w", q, err) + } + } else { + err := r.doSearch(r.selectArtist(), q, offset, size, includeMissing, &res, "json_extract(stats, '$.total.m') desc", "name") + if err != nil { + return nil, fmt.Errorf("searching artist by query %q: %w", q, err) + } } - return dba.toModels(), nil + return res.toModels(), nil } func (r *artistRepository) Count(options ...rest.QueryOptions) (int64, error) { diff --git a/persistence/artist_repository_test.go b/persistence/artist_repository_test.go index c85ef95cc..0dc0b087c 100644 --- a/persistence/artist_repository_test.go +++ b/persistence/artist_repository_test.go @@ -404,4 +404,69 @@ var _ = Describe("ArtistRepository", func() { Expect(roleFilter("", "artist') SELECT LIKE(CHAR(65,66,67,68,69,70,71),UPPER(HEX(RANDOMBLOB(500000000/2))))--")).To(Equal(squirrel.Eq{"1": 2})) }) }) + + Context("MBID Search", func() { + var artistWithMBID model.Artist + var raw *artistRepository + + BeforeEach(func() { + raw = repo.(*artistRepository) + // Create a test artist with MBID + artistWithMBID = model.Artist{ + ID: "test-mbid-artist", + Name: "Test MBID Artist", + MbzArtistID: "550e8400-e29b-41d4-a716-446655440010", // Valid UUID v4 + } + + // Insert the test artist into the database + err := repo.Put(&artistWithMBID) + Expect(err).ToNot(HaveOccurred()) + }) + + AfterEach(func() { + // Clean up test data using direct SQL + _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": artistWithMBID.ID})) + }) + + It("finds artist by mbz_artist_id", func() { + results, err := repo.Search("550e8400-e29b-41d4-a716-446655440010", 0, 10, false) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].ID).To(Equal("test-mbid-artist")) + Expect(results[0].Name).To(Equal("Test MBID Artist")) + }) + + It("returns empty result when MBID is not found", func() { + results, err := repo.Search("550e8400-e29b-41d4-a716-446655440099", 0, 10, false) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(BeEmpty()) + }) + + It("handles includeMissing parameter for MBID search", func() { + // Create a missing artist with MBID + missingArtist := model.Artist{ + ID: "test-missing-mbid-artist", + Name: "Test Missing MBID Artist", + MbzArtistID: "550e8400-e29b-41d4-a716-446655440012", + Missing: true, + } + + err := repo.Put(&missingArtist) + Expect(err).ToNot(HaveOccurred()) + + // Should not find missing artist when includeMissing is false + results, err := repo.Search("550e8400-e29b-41d4-a716-446655440012", 0, 10, false) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(BeEmpty()) + + // Should find missing artist when includeMissing is true + results, err = repo.Search("550e8400-e29b-41d4-a716-446655440012", 0, 10, true) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].ID).To(Equal("test-missing-mbid-artist")) + + // Clean up + _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": missingArtist.ID})) + }) + }) }) diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index d12dd71ba..dd22b1413 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -9,6 +9,7 @@ import ( . "github.com/Masterminds/squirrel" "github.com/deluan/rest" + "github.com/google/uuid" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -90,7 +91,7 @@ func NewMediaFileRepository(ctx context.Context, db dbx.Builder) model.MediaFile var mediaFileFilter = sync.OnceValue(func() map[string]filterFunc { filters := map[string]filterFunc{ "id": idFilter("media_file"), - "title": fullTextFilter("media_file"), + "title": fullTextFilter("media_file", "mbz_recording_id", "mbz_release_track_id"), "starred": booleanFilter, "genre_id": tagIDFilter, "missing": booleanFilter, @@ -294,12 +295,19 @@ func (r *mediaFileRepository) GetMissingAndMatching(libId int) (model.MediaFileC } func (r *mediaFileRepository) Search(q string, offset int, size int, includeMissing bool) (model.MediaFiles, error) { - results := dbMediaFiles{} - err := r.doSearch(r.selectMediaFile(), q, offset, size, includeMissing, &results, "title") - if err != nil { - return nil, err + var res dbMediaFiles + if uuid.Validate(q) == nil { + err := r.searchByMBID(r.selectMediaFile(), q, []string{"mbz_recording_id", "mbz_release_track_id"}, includeMissing, &res) + if err != nil { + return nil, fmt.Errorf("searching media_file by MBID %q: %w", q, err) + } + } else { + err := r.doSearch(r.selectMediaFile(), q, offset, size, includeMissing, &res, "title") + if err != nil { + return nil, fmt.Errorf("searching media_file by query %q: %w", q, err) + } } - return results.toModels(), err + return res.toModels(), nil } func (r *mediaFileRepository) Count(options ...rest.QueryOptions) (int64, error) { diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index b364ca2e8..b1153b317 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -310,4 +310,110 @@ var _ = Describe("MediaRepository", func() { }) }) + + Describe("Search", func() { + Context("text search", func() { + It("finds media files by title", func() { + results, err := mr.Search("Antenna", 0, 10, false) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(3)) // songAntenna, songAntennaWithLyrics, songAntenna2 + for _, result := range results { + Expect(result.Title).To(Equal("Antenna")) + } + }) + + It("finds media files case insensitively", func() { + results, err := mr.Search("antenna", 0, 10, false) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(3)) + for _, result := range results { + Expect(result.Title).To(Equal("Antenna")) + } + }) + + It("returns empty result when no matches found", func() { + results, err := mr.Search("nonexistent", 0, 10, false) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(BeEmpty()) + }) + }) + + Context("MBID search", func() { + var mediaFileWithMBID model.MediaFile + var raw *mediaFileRepository + + BeforeEach(func() { + raw = mr.(*mediaFileRepository) + // Create a test media file with MBID + mediaFileWithMBID = model.MediaFile{ + ID: "test-mbid-mediafile", + Title: "Test MBID MediaFile", + MbzRecordingID: "550e8400-e29b-41d4-a716-446655440020", // Valid UUID v4 + MbzReleaseTrackID: "550e8400-e29b-41d4-a716-446655440021", // Valid UUID v4 + LibraryID: 1, + Path: "/test/path/test.mp3", + } + + // Insert the test media file into the database + err := mr.Put(&mediaFileWithMBID) + Expect(err).ToNot(HaveOccurred()) + }) + + AfterEach(func() { + // Clean up test data using direct SQL + _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": mediaFileWithMBID.ID})) + }) + + It("finds media file by mbz_recording_id", func() { + results, err := mr.Search("550e8400-e29b-41d4-a716-446655440020", 0, 10, false) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].ID).To(Equal("test-mbid-mediafile")) + Expect(results[0].Title).To(Equal("Test MBID MediaFile")) + }) + + It("finds media file by mbz_release_track_id", func() { + results, err := mr.Search("550e8400-e29b-41d4-a716-446655440021", 0, 10, false) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].ID).To(Equal("test-mbid-mediafile")) + Expect(results[0].Title).To(Equal("Test MBID MediaFile")) + }) + + It("returns empty result when MBID is not found", func() { + results, err := mr.Search("550e8400-e29b-41d4-a716-446655440099", 0, 10, false) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(BeEmpty()) + }) + + It("handles includeMissing parameter for MBID search", func() { + // Create a missing media file with MBID + missingMediaFile := model.MediaFile{ + ID: "test-missing-mbid-mediafile", + Title: "Test Missing MBID MediaFile", + MbzRecordingID: "550e8400-e29b-41d4-a716-446655440022", + LibraryID: 1, + Path: "/test/path/missing.mp3", + Missing: true, + } + + err := mr.Put(&missingMediaFile) + Expect(err).ToNot(HaveOccurred()) + + // Should not find missing media file when includeMissing is false + results, err := mr.Search("550e8400-e29b-41d4-a716-446655440022", 0, 10, false) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(BeEmpty()) + + // Should find missing media file when includeMissing is true + results, err = mr.Search("550e8400-e29b-41d4-a716-446655440022", 0, 10, true) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].ID).To(Equal("test-missing-mbid-mediafile")) + + // Clean up + _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": missingMediaFile.ID})) + }) + }) + }) }) diff --git a/persistence/sql_restful.go b/persistence/sql_restful.go index 6be368b00..ff0d06a8b 100644 --- a/persistence/sql_restful.go +++ b/persistence/sql_restful.go @@ -1,6 +1,7 @@ package persistence import ( + "cmp" "context" "fmt" "reflect" @@ -105,8 +106,15 @@ func booleanFilter(field string, value any) Sqlizer { return Eq{field: v == "true"} } -func fullTextFilter(tableName string) func(string, any) Sqlizer { - return func(field string, value any) Sqlizer { return fullTextExpr(tableName, value.(string)) } +func fullTextFilter(tableName string, mbidFields ...string) func(string, any) Sqlizer { + return func(field string, value any) Sqlizer { + v := strings.ToLower(value.(string)) + cond := cmp.Or( + mbidExpr(tableName, v, mbidFields...), + fullTextExpr(tableName, v), + ) + return cond + } } func substringFilter(field string, value any) Sqlizer { diff --git a/persistence/sql_restful_test.go b/persistence/sql_restful_test.go index 20cc31a36..fd95fbb31 100644 --- a/persistence/sql_restful_test.go +++ b/persistence/sql_restful_test.go @@ -2,9 +2,12 @@ package persistence import ( "context" + "strings" "github.com/Masterminds/squirrel" "github.com/deluan/rest" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -66,4 +69,167 @@ var _ = Describe("sqlRestful", func() { Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Gt{"test": 100}})) }) }) + + Describe("fullTextFilter function", func() { + var filter filterFunc + var tableName string + var mbidFields []string + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + tableName = "test_table" + mbidFields = []string{"mbid", "artist_mbid"} + filter = fullTextFilter(tableName, mbidFields...) + }) + + Context("when value is a valid UUID", func() { + It("returns only the mbid filter (precedence over full text)", func() { + uuid := "550e8400-e29b-41d4-a716-446655440000" + result := filter("search", uuid) + + expected := squirrel.Or{ + squirrel.Eq{"test_table.mbid": uuid}, + squirrel.Eq{"test_table.artist_mbid": uuid}, + } + Expect(result).To(Equal(expected)) + }) + + It("falls back to full text when no mbid fields are provided", func() { + noMbidFilter := fullTextFilter(tableName) + uuid := "550e8400-e29b-41d4-a716-446655440000" + result := noMbidFilter("search", uuid) + + // mbidExpr with no fields returns nil, so cmp.Or falls back to fullTextExpr + expected := squirrel.And{ + squirrel.Like{"test_table.full_text": "% 550e8400-e29b-41d4-a716-446655440000%"}, + } + Expect(result).To(Equal(expected)) + }) + }) + + Context("when value is not a valid UUID", func() { + It("returns full text search condition only", func() { + result := filter("search", "beatles") + + // mbidExpr returns nil for non-UUIDs, so fullTextExpr result is returned directly + expected := squirrel.And{ + squirrel.Like{"test_table.full_text": "% beatles%"}, + } + Expect(result).To(Equal(expected)) + }) + + It("handles multi-word search terms", func() { + result := filter("search", "the beatles abbey road") + + // Should return And condition directly + andCondition, ok := result.(squirrel.And) + Expect(ok).To(BeTrue()) + Expect(andCondition).To(HaveLen(4)) + + // Check that all words are present (order may vary) + Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% the%"})) + Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% beatles%"})) + Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% abbey%"})) + Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% road%"})) + }) + }) + + Context("when SearchFullString config changes behavior", func() { + It("uses different separator with SearchFullString=false", func() { + conf.Server.SearchFullString = false + result := filter("search", "test query") + + andCondition, ok := result.(squirrel.And) + Expect(ok).To(BeTrue()) + Expect(andCondition).To(HaveLen(2)) + + // Check that all words are present with leading space (order may vary) + Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% test%"})) + Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% query%"})) + }) + + It("uses no separator with SearchFullString=true", func() { + conf.Server.SearchFullString = true + result := filter("search", "test query") + + andCondition, ok := result.(squirrel.And) + Expect(ok).To(BeTrue()) + Expect(andCondition).To(HaveLen(2)) + + // Check that all words are present without leading space (order may vary) + Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "%test%"})) + Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "%query%"})) + }) + }) + + Context("edge cases", func() { + It("returns nil for empty string", func() { + result := filter("search", "") + Expect(result).To(BeNil()) + }) + + It("returns nil for string with only whitespace", func() { + result := filter("search", " ") + Expect(result).To(BeNil()) + }) + + It("handles special characters that are sanitized", func() { + result := filter("search", "don't") + + expected := squirrel.And{ + squirrel.Like{"test_table.full_text": "% dont%"}, // str.SanitizeStrings removes quotes + } + Expect(result).To(Equal(expected)) + }) + + It("returns nil for single quote (SQL injection protection)", func() { + result := filter("search", "'") + Expect(result).To(BeNil()) + }) + + It("handles mixed case UUIDs", func() { + uuid := "550E8400-E29B-41D4-A716-446655440000" + result := filter("search", uuid) + + // Should return only mbid filter (uppercase UUID should work) + expected := squirrel.Or{ + squirrel.Eq{"test_table.mbid": strings.ToLower(uuid)}, + squirrel.Eq{"test_table.artist_mbid": strings.ToLower(uuid)}, + } + Expect(result).To(Equal(expected)) + }) + + It("handles invalid UUID format gracefully", func() { + result := filter("search", "550e8400-invalid-uuid") + + // Should return full text filter since UUID is invalid + expected := squirrel.And{ + squirrel.Like{"test_table.full_text": "% 550e8400-invalid-uuid%"}, + } + Expect(result).To(Equal(expected)) + }) + + It("handles empty mbid fields array", func() { + emptyMbidFilter := fullTextFilter(tableName, []string{}...) + result := emptyMbidFilter("search", "test") + + // mbidExpr with empty fields returns nil, so cmp.Or falls back to fullTextExpr + expected := squirrel.And{ + squirrel.Like{"test_table.full_text": "% test%"}, + } + Expect(result).To(Equal(expected)) + }) + + It("converts value to lowercase before processing", func() { + result := filter("search", "TEST") + + // The function converts to lowercase internally + expected := squirrel.And{ + squirrel.Like{"test_table.full_text": "% test%"}, + } + Expect(result).To(Equal(expected)) + }) + }) + }) + }) diff --git a/persistence/sql_search.go b/persistence/sql_search.go index 9ac171263..3aea958cd 100644 --- a/persistence/sql_search.go +++ b/persistence/sql_search.go @@ -4,6 +4,7 @@ import ( "strings" . "github.com/Masterminds/squirrel" + "github.com/google/uuid" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/str" @@ -21,9 +22,6 @@ func (r sqlRepository) doSearch(sq SelectBuilder, q string, offset, size int, in return nil } - //sq := r.newSelect().Columns(r.tableName + ".*") - //sq = r.withAnnotation(sq, r.tableName+".id") - //sq = r.withBookmark(sq, r.tableName+".id") filter := fullTextExpr(r.tableName, q) if filter != nil { sq = sq.Where(filter) @@ -40,6 +38,28 @@ func (r sqlRepository) doSearch(sq SelectBuilder, q string, offset, size int, in return r.queryAll(sq, results, model.QueryOptions{Offset: offset}) } +func (r sqlRepository) searchByMBID(sq SelectBuilder, mbid string, mbidFields []string, includeMissing bool, results any) error { + sq = sq.Where(mbidExpr(r.tableName, mbid, mbidFields...)) + + if !includeMissing { + sq = sq.Where(Eq{r.tableName + ".missing": false}) + } + + return r.queryAll(sq, results) +} + +func mbidExpr(tableName, mbid string, mbidFields ...string) Sqlizer { + if uuid.Validate(mbid) != nil || len(mbidFields) == 0 { + return nil + } + mbid = strings.ToLower(mbid) + var cond []Sqlizer + for _, mbidField := range mbidFields { + cond = append(cond, Eq{tableName + "." + mbidField: mbid}) + } + return Or(cond) +} + func fullTextExpr(tableName string, s string) Sqlizer { q := str.SanitizeStrings(s) if q == "" {