Deluan Quintão dce48ff650
feat(smartplaylist): add album-level fields for sorting and filtering (#5899)
Smart playlists could only sort by the track-level `dateadded`, which scatters
an album's tracks because every track has its own timestamp. There was no way
to express "newest albums first, tracks in album order".

Adds five fields backed by the album table: albumdateadded, albumdatemodified,
albumduration, albumsongcount and albumsize, so `"sort": "-albumdateadded,
tracknumber"` now works. They filter as well as sort, which also enables rules
like "everything from albums added this month" or "skip singles and EPs".

These need the album table rather than the album_annotation table the existing
album* fields join, so they get their own bit in the join mask. The bitmask
already unions joins from both the expression and the sort fields, so a
sort-only reference pulls in the join for the main query while correctly
staying out of the percentage-limit count query.

No COALESCE default is used. That mechanism exists because an album_annotation
row is genuinely often absent; the album row always exists and song_count,
duration and size are NOT NULL, so leaving the columns bare keeps filters
index-friendly.

albumdatemodified follows the existing track-level `datemodified` naming rather
than the `albumdateupdated` spelling used in the request.

albumreleasedate was considered and rejected: album.release_date is
allOrNothing() over the tracks, so it equals the track-level releasedate when
they agree and collapses to empty when they disagree.

Closes https://github.com/navidrome/navidrome/discussions/5347
2026-08-06 10:59:50 -04:00

181 lines
5.7 KiB
Go

package criteria
import "strings"
// FieldInfo contains semantic metadata about a criteria field.
type FieldInfo struct {
Alias string // If set, this field is a backward-compat alias for another canonical name
IsTag bool
IsRole bool
Numeric bool
Boolean bool
// Nullable: isMissing/isPresent are supported on this column field. For numeric/boolean
// fields, missing means NULL; for string fields it means NULL or empty string.
Nullable bool
tagAlias string // If set, a tag name from mappings.yaml 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": {},
"album": {Nullable: true},
"hascoverart": {Boolean: true},
"tracknumber": {},
"discnumber": {},
"year": {},
"date": {tagAlias: "recordingdate"},
"originalyear": {},
"originaldate": {},
"releaseyear": {},
"releasedate": {},
"size": {},
"compilation": {Boolean: true},
"missing": {Boolean: true},
"explicitstatus": {Nullable: true},
"dateadded": {},
"datemodified": {},
"discsubtitle": {Nullable: true},
"comment": {Nullable: true},
"lyrics": {Nullable: true},
"sorttitle": {Nullable: true},
"sortalbum": {Nullable: true},
"sortartist": {Nullable: true},
"sortalbumartist": {Nullable: true},
"albumcomment": {Nullable: true},
"catalognumber": {Nullable: true},
"filepath": {},
"filetype": {},
"codec": {},
"duration": {},
"bitrate": {},
"bitdepth": {Numeric: true, Nullable: true},
"samplerate": {},
"bpm": {Numeric: true, Nullable: true},
"channels": {},
"loved": {Boolean: true},
"dateloved": {},
"lastplayed": {},
"daterated": {},
"playcount": {},
"rating": {},
"averagerating": {Numeric: true},
"albumrating": {},
"albumloved": {Boolean: true},
"albumplaycount": {},
"albumlastplayed": {},
"albumdateloved": {},
"albumdaterated": {},
"albumdateadded": {},
"albumdatemodified": {},
"albumduration": {},
"albumsongcount": {},
"albumsize": {},
"artistrating": {},
"artistloved": {Boolean: true},
"artistplaycount": {},
"artistlastplayed": {},
"artistdateloved": {},
"artistdaterated": {},
"mbz_album_id": {Nullable: true},
"mbz_album_artist_id": {Nullable: true},
"mbz_artist_id": {Nullable: true},
"mbz_recording_id": {Nullable: true},
"mbz_release_track_id": {Nullable: true},
"mbz_release_group_id": {Nullable: true},
"rgalbumgain": {Numeric: true, Nullable: true},
"rgalbumpeak": {Numeric: true, Nullable: true},
"rgtrackgain": {Numeric: true, Nullable: true},
"rgtrackpeak": {Numeric: true, Nullable: true},
"library_id": {Numeric: true},
// Backward compatibility: albumtype is an alias for the releasetype tag.
"albumtype": {Alias: "releasetype", IsTag: true},
// Backward compatibility: the replaygain_* tag names (as written in metadata and in the
// PR #5256 example) are aliases for the canonical rg* column fields. Without these, the tag
// names would be registered as empty tags from mappings.yaml and isMissing would always match.
"replaygain_album_gain": {Alias: "rgalbumgain", Numeric: true, Nullable: true},
"replaygain_album_peak": {Alias: "rgalbumpeak", Numeric: true, Nullable: true},
"replaygain_track_gain": {Alias: "rgtrackgain", Numeric: true, Nullable: true},
"replaygain_track_peak": {Alias: "rgtrackpeak", Numeric: true, Nullable: true},
// Pseudo-field for random sorting
"random": {},
}
// AllFieldNames returns the names of all registered criteria fields.
func AllFieldNames() []string {
names := make([]string, 0, len(fieldMap))
for name := range fieldMap {
names = append(names, name)
}
return names
}
// LookupField returns semantic metadata for a criteria field name.
func LookupField(name string) (FieldInfo, bool) {
key := strings.ToLower(name)
f, ok := fieldMap[key]
if ok {
if f.Alias != "" {
f.name = f.Alias
} else {
f.name = key
}
}
return f, ok
}
// 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.
func AddRoles(roles []string) {
for _, role := range roles {
name := strings.ToLower(role)
if _, ok := fieldMap[name]; ok {
continue
}
fieldMap[name] = FieldInfo{IsRole: true}
}
}
// AddTagNames adds tag names to the field map. This is used to add all tags mapped in the `mappings.yaml`
// configuration file.
func AddTagNames(tagNames []string) {
for _, tagName := range tagNames {
name := strings.ToLower(tagName)
if _, ok := fieldMap[name]; ok {
continue
}
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{IsTag: true}
}
}
}
// AddNumericTags adds tags that should be treated as numbers.
func AddNumericTags(tagNames []string) {
for _, tagName := range tagNames {
name := strings.ToLower(tagName)
if fm, ok := fieldMap[name]; ok {
fm.Numeric = true
fieldMap[name] = fm
} else {
fieldMap[name] = FieldInfo{IsTag: true, Numeric: true}
}
}
}