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"
// FieldInfo contains semantic metadata about a criteria field
// FieldInfo contains semantic metadata about a criteria field.
type FieldInfo struct {
Name string
Alias string // If set, this field is a backward-compat alias for another canonical name
IsTag bool
IsRole 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{
"title": {Name: "title"},
"album": {Name: "album"},
"hascoverart": {Name: "hascoverart"},
"tracknumber": {Name: "tracknumber"},
"discnumber": {Name: "discnumber"},
"year": {Name: "year"},
"date": {Name: "date", alias: "recordingdate"},
"originalyear": {Name: "originalyear"},
"originaldate": {Name: "originaldate"},
"releaseyear": {Name: "releaseyear"},
"releasedate": {Name: "releasedate"},
"size": {Name: "size"},
"compilation": {Name: "compilation"},
"missing": {Name: "missing"},
"explicitstatus": {Name: "explicitstatus"},
"dateadded": {Name: "dateadded"},
"datemodified": {Name: "datemodified"},
"discsubtitle": {Name: "discsubtitle"},
"comment": {Name: "comment"},
"lyrics": {Name: "lyrics"},
"sorttitle": {Name: "sorttitle"},
"sortalbum": {Name: "sortalbum"},
"sortartist": {Name: "sortartist"},
"sortalbumartist": {Name: "sortalbumartist"},
"albumcomment": {Name: "albumcomment"},
"catalognumber": {Name: "catalognumber"},
"filepath": {Name: "filepath"},
"filetype": {Name: "filetype"},
"codec": {Name: "codec"},
"duration": {Name: "duration"},
"bitrate": {Name: "bitrate"},
"bitdepth": {Name: "bitdepth"},
"samplerate": {Name: "samplerate"},
"bpm": {Name: "bpm"},
"channels": {Name: "channels"},
"loved": {Name: "loved"},
"dateloved": {Name: "dateloved"},
"lastplayed": {Name: "lastplayed"},
"daterated": {Name: "daterated"},
"playcount": {Name: "playcount"},
"rating": {Name: "rating"},
"averagerating": {Name: "averagerating", Numeric: true},
"albumrating": {Name: "albumrating"},
"albumloved": {Name: "albumloved"},
"albumplaycount": {Name: "albumplaycount"},
"albumlastplayed": {Name: "albumlastplayed"},
"albumdateloved": {Name: "albumdateloved"},
"albumdaterated": {Name: "albumdaterated"},
"artistrating": {Name: "artistrating"},
"artistloved": {Name: "artistloved"},
"artistplaycount": {Name: "artistplaycount"},
"artistlastplayed": {Name: "artistlastplayed"},
"artistdateloved": {Name: "artistdateloved"},
"artistdaterated": {Name: "artistdaterated"},
"mbz_album_id": {Name: "mbz_album_id"},
"mbz_album_artist_id": {Name: "mbz_album_artist_id"},
"mbz_artist_id": {Name: "mbz_artist_id"},
"mbz_recording_id": {Name: "mbz_recording_id"},
"mbz_release_track_id": {Name: "mbz_release_track_id"},
"mbz_release_group_id": {Name: "mbz_release_group_id"},
"rgalbumgain": {Name: "rgalbumgain", Numeric: true},
"rgalbumpeak": {Name: "rgalbumpeak", Numeric: true},
"rgtrackgain": {Name: "rgtrackgain", Numeric: true},
"rgtrackpeak": {Name: "rgtrackpeak", Numeric: true},
"library_id": {Name: "library_id", Numeric: true},
"title": {},
"album": {},
"hascoverart": {},
"tracknumber": {},
"discnumber": {},
"year": {},
"date": {tagAlias: "recordingdate"},
"originalyear": {},
"originaldate": {},
"releaseyear": {},
"releasedate": {},
"size": {},
"compilation": {},
"missing": {},
"explicitstatus": {},
"dateadded": {},
"datemodified": {},
"discsubtitle": {},
"comment": {},
"lyrics": {},
"sorttitle": {},
"sortalbum": {},
"sortartist": {},
"sortalbumartist": {},
"albumcomment": {},
"catalognumber": {},
"filepath": {},
"filetype": {},
"codec": {},
"duration": {},
"bitrate": {},
"bitdepth": {},
"samplerate": {},
"bpm": {},
"channels": {},
"loved": {},
"dateloved": {},
"lastplayed": {},
"daterated": {},
"playcount": {},
"rating": {},
"averagerating": {Numeric: true},
"albumrating": {},
"albumloved": {},
"albumplaycount": {},
"albumlastplayed": {},
"albumdateloved": {},
"albumdaterated": {},
"artistrating": {},
"artistloved": {},
"artistplaycount": {},
"artistlastplayed": {},
"artistdateloved": {},
"artistdaterated": {},
"mbz_album_id": {},
"mbz_album_artist_id": {},
"mbz_artist_id": {},
"mbz_recording_id": {},
"mbz_release_track_id": {},
"mbz_release_group_id": {},
"rgalbumgain": {Numeric: true},
"rgalbumpeak": {Numeric: true},
"rgtrackgain": {Numeric: true},
"rgtrackpeak": {Numeric: true},
"library_id": {Numeric: true},
// Backward compatibility: albumtype is an alias for the releasetype tag.
"albumtype": {Name: "releasetype", IsTag: true},
"albumtype": {Alias: "releasetype", IsTag: true},
"random": {Name: "random"},
"value": {Name: "value"},
"random": {},
"value": {},
}
// 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.
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
}
@ -108,7 +123,7 @@ func AddRoles(roles []string) {
if _, ok := fieldMap[name]; ok {
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 {
continue
}
for _, fm := range fieldMap {
if fm.alias == name {
for key, fm := range fieldMap {
if fm.tagAlias == name {
fm.Alias = key
fm.tagAlias = ""
fieldMap[name] = fm
break
}
}
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
fieldMap[name] = fm
} 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")
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")
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())
})
@ -26,7 +26,7 @@ var _ = Describe("fields", func() {
field, ok := LookupField("value")
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() {
@ -35,7 +35,7 @@ var _ = Describe("fields", func() {
field, ok := LookupField("task3_mood")
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())
})
@ -56,7 +56,7 @@ var _ = Describe("fields", func() {
field, ok := LookupField("task3_producer")
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())
})
})

View File

@ -43,7 +43,7 @@ func (c Criteria) OrderByFields() []SortField {
if order == "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 {
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 {
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 {
@ -412,7 +412,7 @@ func sqlFields(values map[string]any) (map[string]any, error) {
if info.IsTag || info.IsRole {
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 == "" {
return nil, fmt.Errorf("invalid field in criteria: %s", field)
}
@ -431,7 +431,7 @@ func fieldJoinType(name string) smartPlaylistJoinType {
if !ok {
return smartPlaylistJoinNone
}
field, ok := smartPlaylistFields[info.Name]
field, ok := smartPlaylistFields[info.Name()]
if !ok {
return smartPlaylistJoinNone
}
@ -479,17 +479,17 @@ func sortExpr(sortField string) (string, bool) {
if !ok {
return "", false
}
if field, ok := smartPlaylistFields[info.Name]; ok && field.order != "" {
if field, ok := smartPlaylistFields[info.Name()]; ok && field.order != "" {
return field.order, true
}
var mapped string
switch {
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:
mapped = "COALESCE(json_extract(media_file.participants, '$." + info.Name + "[0].name'), '')"
mapped = "COALESCE(json_extract(media_file.participants, '$." + info.Name() + "[0].name'), '')"
default:
field, ok := smartPlaylistFields[info.Name]
field, ok := smartPlaylistFields[info.Name()]
if !ok || field.expr == "" {
return "", false
}

View File

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