refactor(smartplaylist): clarify FieldInfo naming in criteria package

Rename FieldInfo.Name to Alias (only meaningful for backward-compat
entries like albumtype→releasetype) and FieldInfo.alias to tagAlias
(tag names from mappings.yml that resolve to existing fields). Add a
Name() method that derives the canonical name from the map key,
eliminating 60+ redundant Name declarations where the value matched
the key. LookupField now populates the private name field automatically.

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2026-04-28 20:22:48 -04:00
parent d9dac44456
commit 0fd9c6df2e
5 changed files with 111 additions and 94 deletions

View File

@ -2,87 +2,94 @@ package criteria
import "strings" import "strings"
// FieldInfo contains semantic metadata about a criteria field // FieldInfo contains semantic metadata about a criteria field.
type FieldInfo struct { type FieldInfo struct {
Name string Alias string // If set, this field is a backward-compat alias for another canonical name
IsTag bool IsTag bool
IsRole bool IsRole bool
Numeric bool Numeric bool
alias string
tagAlias string // If set, a tag name from mappings.yml that resolves to this field
name string // Canonical name, populated by LookupField from the map key
}
// Name returns the canonical field name (the map key used to register this field).
func (f FieldInfo) Name() string {
return f.name
} }
var fieldMap = map[string]FieldInfo{ var fieldMap = map[string]FieldInfo{
"title": {Name: "title"}, "title": {},
"album": {Name: "album"}, "album": {},
"hascoverart": {Name: "hascoverart"}, "hascoverart": {},
"tracknumber": {Name: "tracknumber"}, "tracknumber": {},
"discnumber": {Name: "discnumber"}, "discnumber": {},
"year": {Name: "year"}, "year": {},
"date": {Name: "date", alias: "recordingdate"}, "date": {tagAlias: "recordingdate"},
"originalyear": {Name: "originalyear"}, "originalyear": {},
"originaldate": {Name: "originaldate"}, "originaldate": {},
"releaseyear": {Name: "releaseyear"}, "releaseyear": {},
"releasedate": {Name: "releasedate"}, "releasedate": {},
"size": {Name: "size"}, "size": {},
"compilation": {Name: "compilation"}, "compilation": {},
"missing": {Name: "missing"}, "missing": {},
"explicitstatus": {Name: "explicitstatus"}, "explicitstatus": {},
"dateadded": {Name: "dateadded"}, "dateadded": {},
"datemodified": {Name: "datemodified"}, "datemodified": {},
"discsubtitle": {Name: "discsubtitle"}, "discsubtitle": {},
"comment": {Name: "comment"}, "comment": {},
"lyrics": {Name: "lyrics"}, "lyrics": {},
"sorttitle": {Name: "sorttitle"}, "sorttitle": {},
"sortalbum": {Name: "sortalbum"}, "sortalbum": {},
"sortartist": {Name: "sortartist"}, "sortartist": {},
"sortalbumartist": {Name: "sortalbumartist"}, "sortalbumartist": {},
"albumcomment": {Name: "albumcomment"}, "albumcomment": {},
"catalognumber": {Name: "catalognumber"}, "catalognumber": {},
"filepath": {Name: "filepath"}, "filepath": {},
"filetype": {Name: "filetype"}, "filetype": {},
"codec": {Name: "codec"}, "codec": {},
"duration": {Name: "duration"}, "duration": {},
"bitrate": {Name: "bitrate"}, "bitrate": {},
"bitdepth": {Name: "bitdepth"}, "bitdepth": {},
"samplerate": {Name: "samplerate"}, "samplerate": {},
"bpm": {Name: "bpm"}, "bpm": {},
"channels": {Name: "channels"}, "channels": {},
"loved": {Name: "loved"}, "loved": {},
"dateloved": {Name: "dateloved"}, "dateloved": {},
"lastplayed": {Name: "lastplayed"}, "lastplayed": {},
"daterated": {Name: "daterated"}, "daterated": {},
"playcount": {Name: "playcount"}, "playcount": {},
"rating": {Name: "rating"}, "rating": {},
"averagerating": {Name: "averagerating", Numeric: true}, "averagerating": {Numeric: true},
"albumrating": {Name: "albumrating"}, "albumrating": {},
"albumloved": {Name: "albumloved"}, "albumloved": {},
"albumplaycount": {Name: "albumplaycount"}, "albumplaycount": {},
"albumlastplayed": {Name: "albumlastplayed"}, "albumlastplayed": {},
"albumdateloved": {Name: "albumdateloved"}, "albumdateloved": {},
"albumdaterated": {Name: "albumdaterated"}, "albumdaterated": {},
"artistrating": {Name: "artistrating"}, "artistrating": {},
"artistloved": {Name: "artistloved"}, "artistloved": {},
"artistplaycount": {Name: "artistplaycount"}, "artistplaycount": {},
"artistlastplayed": {Name: "artistlastplayed"}, "artistlastplayed": {},
"artistdateloved": {Name: "artistdateloved"}, "artistdateloved": {},
"artistdaterated": {Name: "artistdaterated"}, "artistdaterated": {},
"mbz_album_id": {Name: "mbz_album_id"}, "mbz_album_id": {},
"mbz_album_artist_id": {Name: "mbz_album_artist_id"}, "mbz_album_artist_id": {},
"mbz_artist_id": {Name: "mbz_artist_id"}, "mbz_artist_id": {},
"mbz_recording_id": {Name: "mbz_recording_id"}, "mbz_recording_id": {},
"mbz_release_track_id": {Name: "mbz_release_track_id"}, "mbz_release_track_id": {},
"mbz_release_group_id": {Name: "mbz_release_group_id"}, "mbz_release_group_id": {},
"rgalbumgain": {Name: "rgalbumgain", Numeric: true}, "rgalbumgain": {Numeric: true},
"rgalbumpeak": {Name: "rgalbumpeak", Numeric: true}, "rgalbumpeak": {Numeric: true},
"rgtrackgain": {Name: "rgtrackgain", Numeric: true}, "rgtrackgain": {Numeric: true},
"rgtrackpeak": {Name: "rgtrackpeak", Numeric: true}, "rgtrackpeak": {Numeric: true},
"library_id": {Name: "library_id", Numeric: true}, "library_id": {Numeric: true},
// Backward compatibility: albumtype is an alias for the releasetype tag. // Backward compatibility: albumtype is an alias for the releasetype tag.
"albumtype": {Name: "releasetype", IsTag: true}, "albumtype": {Alias: "releasetype", IsTag: true},
"random": {Name: "random"}, "random": {},
"value": {Name: "value"}, "value": {},
} }
// AllFieldNames returns the names of all registered criteria fields. // AllFieldNames returns the names of all registered criteria fields.
@ -96,7 +103,15 @@ func AllFieldNames() []string {
// LookupField returns semantic metadata for a criteria field name. // LookupField returns semantic metadata for a criteria field name.
func LookupField(name string) (FieldInfo, bool) { func LookupField(name string) (FieldInfo, bool) {
f, ok := fieldMap[strings.ToLower(name)] key := strings.ToLower(name)
f, ok := fieldMap[key]
if ok {
if f.Alias != "" {
f.name = f.Alias
} else {
f.name = key
}
}
return f, ok return f, ok
} }
@ -108,7 +123,7 @@ func AddRoles(roles []string) {
if _, ok := fieldMap[name]; ok { if _, ok := fieldMap[name]; ok {
continue continue
} }
fieldMap[name] = FieldInfo{Name: name, IsRole: true} fieldMap[name] = FieldInfo{IsRole: true}
} }
} }
@ -120,14 +135,16 @@ func AddTagNames(tagNames []string) {
if _, ok := fieldMap[name]; ok { if _, ok := fieldMap[name]; ok {
continue continue
} }
for _, fm := range fieldMap { for key, fm := range fieldMap {
if fm.alias == name { if fm.tagAlias == name {
fm.Alias = key
fm.tagAlias = ""
fieldMap[name] = fm fieldMap[name] = fm
break break
} }
} }
if _, ok := fieldMap[name]; !ok { if _, ok := fieldMap[name]; !ok {
fieldMap[name] = FieldInfo{Name: name, IsTag: true} fieldMap[name] = FieldInfo{IsTag: true}
} }
} }
} }
@ -140,7 +157,7 @@ func AddNumericTags(tagNames []string) {
fm.Numeric = true fm.Numeric = true
fieldMap[name] = fm fieldMap[name] = fm
} else { } else {
fieldMap[name] = FieldInfo{Name: name, IsTag: true, Numeric: true} fieldMap[name] = FieldInfo{IsTag: true, Numeric: true}
} }
} }
} }

View File

@ -11,14 +11,14 @@ var _ = Describe("fields", func() {
field, ok := LookupField("Title") field, ok := LookupField("Title")
gomega.Expect(ok).To(gomega.BeTrue()) gomega.Expect(ok).To(gomega.BeTrue())
gomega.Expect(field).To(gomega.Equal(FieldInfo{Name: "title"})) gomega.Expect(field.Name()).To(gomega.Equal("title"))
}) })
It("resolves aliases to their semantic field name", func() { It("resolves aliases to their canonical field name", func() {
field, ok := LookupField("albumtype") field, ok := LookupField("albumtype")
gomega.Expect(ok).To(gomega.BeTrue()) gomega.Expect(ok).To(gomega.BeTrue())
gomega.Expect(field.Name).To(gomega.Equal("releasetype")) gomega.Expect(field.Name()).To(gomega.Equal("releasetype"))
gomega.Expect(field.IsTag).To(gomega.BeTrue()) gomega.Expect(field.IsTag).To(gomega.BeTrue())
}) })
@ -26,7 +26,7 @@ var _ = Describe("fields", func() {
field, ok := LookupField("value") field, ok := LookupField("value")
gomega.Expect(ok).To(gomega.BeTrue()) gomega.Expect(ok).To(gomega.BeTrue())
gomega.Expect(field.Name).To(gomega.Equal("value")) gomega.Expect(field.Name()).To(gomega.Equal("value"))
}) })
It("finds registered tag names", func() { It("finds registered tag names", func() {
@ -35,7 +35,7 @@ var _ = Describe("fields", func() {
field, ok := LookupField("task3_mood") field, ok := LookupField("task3_mood")
gomega.Expect(ok).To(gomega.BeTrue()) gomega.Expect(ok).To(gomega.BeTrue())
gomega.Expect(field.Name).To(gomega.Equal("task3_mood")) gomega.Expect(field.Name()).To(gomega.Equal("task3_mood"))
gomega.Expect(field.IsTag).To(gomega.BeTrue()) gomega.Expect(field.IsTag).To(gomega.BeTrue())
}) })
@ -56,7 +56,7 @@ var _ = Describe("fields", func() {
field, ok := LookupField("task3_producer") field, ok := LookupField("task3_producer")
gomega.Expect(ok).To(gomega.BeTrue()) gomega.Expect(ok).To(gomega.BeTrue())
gomega.Expect(field.Name).To(gomega.Equal("task3_producer")) gomega.Expect(field.Name()).To(gomega.Equal("task3_producer"))
gomega.Expect(field.IsRole).To(gomega.BeTrue()) gomega.Expect(field.IsRole).To(gomega.BeTrue())
}) })
}) })

View File

@ -43,7 +43,7 @@ func (c Criteria) OrderByFields() []SortField {
if order == "desc" { if order == "desc" {
desc = !desc desc = !desc
} }
fields = append(fields, SortField{Field: info.Name, Desc: desc}) fields = append(fields, SortField{Field: info.Name(), Desc: desc})
} }
if len(fields) == 0 { if len(fields) == 0 {
log.Warn("No valid sort fields found in 'sort', falling back to 'title'", "sort", sortValue) log.Warn("No valid sort fields found in 'sort', falling back to 'title'", "sort", sortValue)

View File

@ -338,9 +338,9 @@ func (c smartPlaylistCriteria) inList(values map[string]any, negate bool) (squir
func jsonExpr(info criteria.FieldInfo, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer { func jsonExpr(info criteria.FieldInfo, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer {
if info.IsRole { if info.IsRole {
return roleCond{role: info.Name, cond: cond, not: negate} return roleCond{role: info.Name(), cond: cond, not: negate}
} }
return tagCond{tag: info.Name, numeric: info.Numeric, cond: cond, not: negate} return tagCond{tag: info.Name(), numeric: info.Numeric, cond: cond, not: negate}
} }
type tagCond struct { type tagCond struct {
@ -412,7 +412,7 @@ func sqlFields(values map[string]any) (map[string]any, error) {
if info.IsTag || info.IsRole { if info.IsTag || info.IsRole {
return nil, fmt.Errorf("tag and role criteria must contain exactly one field: %s", field) return nil, fmt.Errorf("tag and role criteria must contain exactly one field: %s", field)
} }
sqlField, ok := fieldExpr(info.Name) sqlField, ok := fieldExpr(info.Name())
if !ok || sqlField == "" { if !ok || sqlField == "" {
return nil, fmt.Errorf("invalid field in criteria: %s", field) return nil, fmt.Errorf("invalid field in criteria: %s", field)
} }
@ -431,7 +431,7 @@ func fieldJoinType(name string) smartPlaylistJoinType {
if !ok { if !ok {
return smartPlaylistJoinNone return smartPlaylistJoinNone
} }
field, ok := smartPlaylistFields[info.Name] field, ok := smartPlaylistFields[info.Name()]
if !ok { if !ok {
return smartPlaylistJoinNone return smartPlaylistJoinNone
} }
@ -479,17 +479,17 @@ func sortExpr(sortField string) (string, bool) {
if !ok { if !ok {
return "", false return "", false
} }
if field, ok := smartPlaylistFields[info.Name]; ok && field.order != "" { if field, ok := smartPlaylistFields[info.Name()]; ok && field.order != "" {
return field.order, true return field.order, true
} }
var mapped string var mapped string
switch { switch {
case info.IsTag: case info.IsTag:
mapped = "COALESCE(json_extract(media_file.tags, '$." + info.Name + "[0].value'), '')" mapped = "COALESCE(json_extract(media_file.tags, '$." + info.Name() + "[0].value'), '')"
case info.IsRole: case info.IsRole:
mapped = "COALESCE(json_extract(media_file.participants, '$." + info.Name + "[0].name'), '')" mapped = "COALESCE(json_extract(media_file.participants, '$." + info.Name() + "[0].name'), '')"
default: default:
field, ok := smartPlaylistFields[info.Name] field, ok := smartPlaylistFields[info.Name()]
if !ok || field.expr == "" { if !ok || field.expr == "" {
return "", false return "", false
} }

View File

@ -194,8 +194,8 @@ var _ = Describe("Smart playlist criteria SQL", func() {
if info.IsTag || info.IsRole { if info.IsTag || info.IsRole {
continue continue
} }
_, hasSQLField := smartPlaylistFields[info.Name] _, hasSQLField := smartPlaylistFields[info.Name()]
Expect(hasSQLField).To(BeTrue(), "criteria field %q (name=%q) has no entry in smartPlaylistFields", name, info.Name) Expect(hasSQLField).To(BeTrue(), "criteria field %q (name=%q) has no entry in smartPlaylistFields", name, info.Name())
} }
}) })