feat: MBID search functionality for albums, artists and songs (#4286)

* feat(subsonic): search by MBID functionality

Updated the search methods in the mediaFileRepository, albumRepository, and artistRepository to support searching by MBID in addition to the existing query methods. This change improves the efficiency of media file, album, and artist searches, allowing for faster retrieval of records based on MBID.

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

* feat(subsonic): enhance MBID search functionality for albums and artists

Updated the search functionality to support searching by MBID for both
albums and artists. The fullTextFilter function was modified to accept
additional MBID fields, allowing for more comprehensive searches. New
tests were added to ensure that the search functionality correctly
handles MBID queries, including cases for missing entries and the
includeMissing parameter. This enhancement improves the overall search
capabilities of the application, making it easier for users to find
specific media items by their unique identifiers.

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

* fix(subsonic): normalize MBID to lowercase for consistent querying

Updated the MBID handling in the SQL search logic to convert the input
to lowercase before executing the query. This change ensures that
searches are case-insensitive, improving the accuracy and reliability
of the search results when querying by MBID.

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan Quintão
2025-06-30 17:11:54 -04:00
committed by GitHub
parent f9c7cc5348
commit bfa5b29913
9 changed files with 438 additions and 22 deletions

View File

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

View File

@@ -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
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, err
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) {

View File

@@ -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")
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, err
return nil, fmt.Errorf("searching artist by MBID %q: %w", q, err)
}
return dba.toModels(), nil
} 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 res.toModels(), nil
}
func (r *artistRepository) Count(options ...rest.QueryOptions) (int64, error) {

View File

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

View File

@@ -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")
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, err
return nil, fmt.Errorf("searching media_file by MBID %q: %w", q, err)
}
return results.toModels(), 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 res.toModels(), nil
}
func (r *mediaFileRepository) Count(options ...rest.QueryOptions) (int64, error) {

View File

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

View File

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

View File

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

View File

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