navidrome/persistence/sql_restful.go
Deluan Quintão bfa5b29913
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>
2025-06-30 17:11:54 -04:00

181 lines
4.4 KiB
Go

package persistence
import (
"cmp"
"context"
"fmt"
"reflect"
"strings"
"sync"
. "github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/fatih/structs"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
type filterFunc = func(field string, value any) Sqlizer
func (r *sqlRepository) parseRestFilters(ctx context.Context, options rest.QueryOptions) Sqlizer {
if len(options.Filters) == 0 {
return nil
}
filters := And{}
for f, v := range options.Filters {
// Ignore filters with empty values
if v == "" {
continue
}
// Look for a custom filter function
f = strings.ToLower(f)
if ff, ok := r.filterMappings[f]; ok {
if filter := ff(f, v); filter != nil {
filters = append(filters, filter)
}
continue
}
// Ignore invalid filters (not based on a field or filter function)
if r.isFieldWhiteListed != nil && !r.isFieldWhiteListed(f) {
log.Warn(ctx, "Ignoring filter not whitelisted", "filter", f, "table", r.tableName)
continue
}
// For fields ending in "id", use an exact match
if strings.HasSuffix(f, "id") {
filters = append(filters, eqFilter(f, v))
continue
}
// Default to a "starts with" filter
filters = append(filters, startsWithFilter(f, v))
}
return filters
}
func (r *sqlRepository) parseRestOptions(ctx context.Context, options ...rest.QueryOptions) model.QueryOptions {
qo := model.QueryOptions{}
if len(options) > 0 {
qo.Sort, qo.Order = r.sanitizeSort(options[0].Sort, options[0].Order)
qo.Max = options[0].Max
qo.Offset = options[0].Offset
if seed, ok := options[0].Filters["seed"].(string); ok {
qo.Seed = seed
delete(options[0].Filters, "seed")
}
qo.Filters = r.parseRestFilters(ctx, options[0])
}
return qo
}
func (r sqlRepository) sanitizeSort(sort, order string) (string, string) {
if sort != "" {
sort = toSnakeCase(sort)
if mapped, ok := r.sortMappings[sort]; ok {
sort = mapped
} else {
if !r.isFieldWhiteListed(sort) {
log.Warn(r.ctx, "Ignoring sort not whitelisted", "sort", sort, "table", r.tableName)
sort = ""
}
}
}
if order != "" {
order = strings.ToLower(order)
if order != "desc" {
order = "asc"
}
}
return sort, order
}
func eqFilter(field string, value any) Sqlizer {
return Eq{field: value}
}
func startsWithFilter(field string, value any) Sqlizer {
return Like{field: fmt.Sprintf("%s%%", value)}
}
func containsFilter(field string) func(string, any) Sqlizer {
return func(_ string, value any) Sqlizer {
return Like{field: fmt.Sprintf("%%%s%%", value)}
}
}
func booleanFilter(field string, value any) Sqlizer {
v := strings.ToLower(value.(string))
return Eq{field: v == "true"}
}
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 {
parts := strings.Fields(value.(string))
filters := And{}
for _, part := range parts {
filters = append(filters, Like{field: "%" + part + "%"})
}
return filters
}
func idFilter(tableName string) func(string, any) Sqlizer {
return func(field string, value any) Sqlizer { return Eq{tableName + ".id": value} }
}
func invalidFilter(ctx context.Context) func(string, any) Sqlizer {
return func(field string, value any) Sqlizer {
log.Warn(ctx, "Invalid filter", "fieldName", field, "value", value)
return Eq{"1": "0"}
}
}
var (
whiteList = map[string]map[string]struct{}{}
mutex sync.RWMutex
)
func registerModelWhiteList(instance any) fieldWhiteListedFunc {
name := reflect.TypeOf(instance).String()
registerFieldWhiteList(name, instance)
return getFieldWhiteListedFunc(name)
}
func registerFieldWhiteList(name string, instance any) {
mutex.Lock()
defer mutex.Unlock()
if whiteList[name] != nil {
return
}
m := structs.Map(instance)
whiteList[name] = map[string]struct{}{}
for k := range m {
whiteList[name][toSnakeCase(k)] = struct{}{}
}
ma := structs.Map(model.Annotations{})
for k := range ma {
whiteList[name][toSnakeCase(k)] = struct{}{}
}
}
type fieldWhiteListedFunc func(field string) bool
func getFieldWhiteListedFunc(tableName string) fieldWhiteListedFunc {
return func(field string) bool {
mutex.RLock()
defer mutex.RUnlock()
if _, ok := whiteList[tableName]; !ok {
return false
}
_, ok := whiteList[tableName][field]
return ok
}
}