mirror of
https://github.com/navidrome/navidrome.git
synced 2026-01-03 06:15:22 +00:00
* fix: prevent infinite loop in Type filter autocomplete Fixed an infinite loop issue in the album Type filter caused by an inline arrow function in the optionText prop. The inline function created a new reference on every render, causing React-Admin's AutocompleteInput to continuously re-fetch data from the /api/tag endpoint. The solution extracts the formatting function outside the component scope as formatReleaseType, ensuring a stable function reference across renders. This prevents unnecessary re-renders and API calls while maintaining the humanized display format for release type values. * fix: enable multi-valued releasetype in smart playlists Smart playlists can now match all values in multi-valued releasetype tags. Previously, the albumtype field was mapped to the single-valued mbz_album_type database field, which only stored the first value from tags like album; soundtrack. This prevented smart playlists from matching albums with secondary release types like soundtrack, live, or compilation when tagged by MusicBrainz Picard. The fix removes the direct database field mapping and allows both albumtype and releasetype to use the multi-valued tag system. The albumtype field is now an alias that points to the releasetype tag field, ensuring both query the same JSON path in the tags column. This maintains backward compatibility with the documented albumtype field while enabling proper multi-value tag matching. Added tests to verify both releasetype and albumtype correctly generate multi-valued tag queries. Fixes #4616 * fix: resolve albumtype alias for all operators and sorting Codex correctly identified that the initial fix only worked for Contains/StartsWith/EndsWith operators. The alias resolution was happening too late in the code path. Fixed by resolving the alias in two places: 1. tagCond.ToSql() - now uses the actual field name (releasetype) in the JSON path 2. Criteria.OrderBy() - now uses the actual field name when building sort expressions Added tests for Is/IsNot operators and sorting to ensure complete coverage.
243 lines
8.2 KiB
Go
243 lines
8.2 KiB
Go
package criteria
|
|
|
|
import (
|
|
"fmt"
|
|
"reflect"
|
|
"strings"
|
|
|
|
"github.com/Masterminds/squirrel"
|
|
"github.com/navidrome/navidrome/log"
|
|
)
|
|
|
|
var fieldMap = map[string]*mappedField{
|
|
"title": {field: "media_file.title"},
|
|
"album": {field: "media_file.album"},
|
|
"hascoverart": {field: "media_file.has_cover_art"},
|
|
"tracknumber": {field: "media_file.track_number"},
|
|
"discnumber": {field: "media_file.disc_number"},
|
|
"year": {field: "media_file.year"},
|
|
"date": {field: "media_file.date", alias: "recordingdate"},
|
|
"originalyear": {field: "media_file.original_year"},
|
|
"originaldate": {field: "media_file.original_date"},
|
|
"releaseyear": {field: "media_file.release_year"},
|
|
"releasedate": {field: "media_file.release_date"},
|
|
"size": {field: "media_file.size"},
|
|
"compilation": {field: "media_file.compilation"},
|
|
"dateadded": {field: "media_file.created_at"},
|
|
"datemodified": {field: "media_file.updated_at"},
|
|
"discsubtitle": {field: "media_file.disc_subtitle"},
|
|
"comment": {field: "media_file.comment"},
|
|
"lyrics": {field: "media_file.lyrics"},
|
|
"sorttitle": {field: "media_file.sort_title"},
|
|
"sortalbum": {field: "media_file.sort_album_name"},
|
|
"sortartist": {field: "media_file.sort_artist_name"},
|
|
"sortalbumartist": {field: "media_file.sort_album_artist_name"},
|
|
"albumcomment": {field: "media_file.mbz_album_comment"},
|
|
"catalognumber": {field: "media_file.catalog_num"},
|
|
"filepath": {field: "media_file.path"},
|
|
"filetype": {field: "media_file.suffix"},
|
|
"duration": {field: "media_file.duration"},
|
|
"bitrate": {field: "media_file.bit_rate"},
|
|
"bitdepth": {field: "media_file.bit_depth"},
|
|
"bpm": {field: "media_file.bpm"},
|
|
"channels": {field: "media_file.channels"},
|
|
"loved": {field: "COALESCE(annotation.starred, false)"},
|
|
"dateloved": {field: "annotation.starred_at"},
|
|
"lastplayed": {field: "annotation.play_date"},
|
|
"playcount": {field: "COALESCE(annotation.play_count, 0)"},
|
|
"rating": {field: "COALESCE(annotation.rating, 0)"},
|
|
"mbz_album_id": {field: "media_file.mbz_album_id"},
|
|
"mbz_album_artist_id": {field: "media_file.mbz_album_artist_id"},
|
|
"mbz_artist_id": {field: "media_file.mbz_artist_id"},
|
|
"mbz_recording_id": {field: "media_file.mbz_recording_id"},
|
|
"mbz_release_track_id": {field: "media_file.mbz_release_track_id"},
|
|
"mbz_release_group_id": {field: "media_file.mbz_release_group_id"},
|
|
"library_id": {field: "media_file.library_id", numeric: true},
|
|
|
|
// Backward compatibility: albumtype is an alias for releasetype tag
|
|
"albumtype": {field: "releasetype", isTag: true},
|
|
|
|
// special fields
|
|
"random": {field: "", order: "random()"}, // pseudo-field for random sorting
|
|
"value": {field: "value"}, // pseudo-field for tag and roles values
|
|
}
|
|
|
|
type mappedField struct {
|
|
field string
|
|
order string
|
|
isRole bool // true if the field is a role (e.g. "artist", "composer", "conductor", etc.)
|
|
isTag bool // true if the field is a tag imported from the file metadata
|
|
alias string // name from `mappings.yml` that may differ from the name used in the smart playlist
|
|
numeric bool // true if the field/tag should be treated as numeric
|
|
}
|
|
|
|
func mapFields(expr map[string]any) map[string]any {
|
|
m := make(map[string]any)
|
|
for f, v := range expr {
|
|
if dbf := fieldMap[strings.ToLower(f)]; dbf != nil && dbf.field != "" {
|
|
m[dbf.field] = v
|
|
} else {
|
|
log.Error("Invalid field in criteria", "field", f)
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
// mapExpr maps a normal field expression to a specific type of expression (tag or role).
|
|
// This is required because tags are handled differently than other fields,
|
|
// as they are stored as a JSON column in the database.
|
|
func mapExpr(expr squirrel.Sqlizer, negate bool, exprFunc func(string, squirrel.Sqlizer, bool) squirrel.Sqlizer) squirrel.Sqlizer {
|
|
rv := reflect.ValueOf(expr)
|
|
if rv.Kind() != reflect.Map || rv.Type().Key().Kind() != reflect.String {
|
|
log.Fatal(fmt.Sprintf("expr is not a map-based operator: %T", expr))
|
|
}
|
|
|
|
// Extract into a generic map
|
|
var k string
|
|
m := make(map[string]any, rv.Len())
|
|
for _, key := range rv.MapKeys() {
|
|
// Save the key to build the expression, and use the provided keyName as the key
|
|
k = key.String()
|
|
m["value"] = rv.MapIndex(key).Interface()
|
|
break // only one key is expected (and supported)
|
|
}
|
|
|
|
// Clear the original map
|
|
for _, key := range rv.MapKeys() {
|
|
rv.SetMapIndex(key, reflect.Value{})
|
|
}
|
|
|
|
// Write the updated map back into the original variable
|
|
for key, val := range m {
|
|
rv.SetMapIndex(reflect.ValueOf(key), reflect.ValueOf(val))
|
|
}
|
|
|
|
return exprFunc(k, expr, negate)
|
|
}
|
|
|
|
// mapTagExpr maps a normal field expression to a tag expression.
|
|
func mapTagExpr(expr squirrel.Sqlizer, negate bool) squirrel.Sqlizer {
|
|
return mapExpr(expr, negate, tagExpr)
|
|
}
|
|
|
|
// mapRoleExpr maps a normal field expression to an artist role expression.
|
|
func mapRoleExpr(expr squirrel.Sqlizer, negate bool) squirrel.Sqlizer {
|
|
return mapExpr(expr, negate, roleExpr)
|
|
}
|
|
|
|
func isTagExpr(expr map[string]any) bool {
|
|
for f := range expr {
|
|
if f2, ok := fieldMap[strings.ToLower(f)]; ok && f2.isTag {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isRoleExpr(expr map[string]any) bool {
|
|
for f := range expr {
|
|
if f2, ok := fieldMap[strings.ToLower(f)]; ok && f2.isRole {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func tagExpr(tag string, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer {
|
|
return tagCond{tag: tag, cond: cond, not: negate}
|
|
}
|
|
|
|
type tagCond struct {
|
|
tag string
|
|
cond squirrel.Sqlizer
|
|
not bool
|
|
}
|
|
|
|
func (e tagCond) ToSql() (string, []any, error) {
|
|
cond, args, err := e.cond.ToSql()
|
|
|
|
// Resolve the actual tag name (handles aliases like albumtype -> releasetype)
|
|
tagName := e.tag
|
|
if fm, ok := fieldMap[e.tag]; ok {
|
|
if fm.field != "" {
|
|
tagName = fm.field
|
|
}
|
|
if fm.numeric {
|
|
cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)")
|
|
}
|
|
}
|
|
|
|
cond = fmt.Sprintf("exists (select 1 from json_tree(tags, '$.%s') where key='value' and %s)",
|
|
tagName, cond)
|
|
if e.not {
|
|
cond = "not " + cond
|
|
}
|
|
return cond, args, err
|
|
}
|
|
|
|
func roleExpr(role string, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer {
|
|
return roleCond{role: role, cond: cond, not: negate}
|
|
}
|
|
|
|
type roleCond struct {
|
|
role string
|
|
cond squirrel.Sqlizer
|
|
not bool
|
|
}
|
|
|
|
func (e roleCond) ToSql() (string, []any, error) {
|
|
cond, args, err := e.cond.ToSql()
|
|
cond = fmt.Sprintf(`exists (select 1 from json_tree(participants, '$.%s') where key='name' and %s)`,
|
|
e.role, cond)
|
|
if e.not {
|
|
cond = "not " + cond
|
|
}
|
|
return cond, args, err
|
|
}
|
|
|
|
// AddRoles adds roles to the field map. This is used to add all artist roles to the field map, so they can be used in
|
|
// smart playlists. If a role already exists in the field map, it is ignored, so calls to this function are idempotent.
|
|
func AddRoles(roles []string) {
|
|
for _, role := range roles {
|
|
name := strings.ToLower(role)
|
|
if _, ok := fieldMap[name]; ok {
|
|
continue
|
|
}
|
|
fieldMap[name] = &mappedField{field: name, isRole: true}
|
|
}
|
|
}
|
|
|
|
// AddTagNames adds tag names to the field map. This is used to add all tags mapped in the `mappings.yml`
|
|
// file to the field map, so they can be used in smart playlists.
|
|
// If a tag name already exists in the field map, it is ignored, so calls to this function are idempotent.
|
|
func AddTagNames(tagNames []string) {
|
|
for _, name := range tagNames {
|
|
name := strings.ToLower(name)
|
|
if _, ok := fieldMap[name]; ok {
|
|
continue
|
|
}
|
|
for _, fm := range fieldMap {
|
|
if fm.alias == name {
|
|
fieldMap[name] = fm
|
|
break
|
|
}
|
|
}
|
|
if _, ok := fieldMap[name]; !ok {
|
|
fieldMap[name] = &mappedField{field: name, isTag: true}
|
|
}
|
|
}
|
|
}
|
|
|
|
// AddNumericTags marks the given tag names as numeric so they can be cast
|
|
// when used in comparisons or sorting.
|
|
func AddNumericTags(tagNames []string) {
|
|
for _, name := range tagNames {
|
|
name := strings.ToLower(name)
|
|
if fm, ok := fieldMap[name]; ok {
|
|
fm.numeric = true
|
|
} else {
|
|
fieldMap[name] = &mappedField{field: name, isTag: true, numeric: true}
|
|
}
|
|
}
|
|
}
|