mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
* feat(smartplaylist): support isMissing/isPresent on mbz_* and lyrics fields Mark the six mbz_* MusicBrainz ID columns and the lyrics column as Nullable in the criteria field map, then extend missingExpr to handle string columns where absence is encoded as NULL or empty string (plus '[]' for lyrics). The Numeric/Boolean path (ReplayGain) is preserved via an explicit type check. * refactor(model): make MediaFile BPM and BitDepth nullable pointers Convert BPM and BitDepth fields in model.MediaFile from int to *int so that 'tag absent' is distinguishable from zero. The metadata mapper now uses NullableFloat for BPM (nil when absent or zero/unparseable) and only sets BitDepth when the audio property is non-zero (lossy codecs report 0). All read sites use gg.V() for zero-fallback deref so Subsonic API output and transcoding behaviour are byte-identical to before. The persistence layer bridges the existing NOT NULL DB columns by coercing nil to 0 on write and 0 back to nil on read in PostMapArgs/PostScan; a later migration task will drop those constraints. Hash upgrade safety is verified by a new MediaFile.Hash describe block: nil *int hashes identically to the old int(0) default via ZeroNil+IgnoreZeroValue, so no files will be spuriously re-imported after this change. Extra files touched beyond the plan's list: core/stream/legacy_client_test.go (BitDepth in model.MediaFile literals), persistence/mediafile_repository.go (NOT NULL bridge). * test(model): pin pre-conversion golden hashes for BPM/BitDepth * feat(smartplaylist): support isMissing/isPresent on bpm and bitDepth * feat(db): make bpm and bit_depth columns nullable, backfill 0 to NULL Drop the NOT NULL constraint on media_file.bpm and bit_depth via a lossless migration that converts legacy 0-means-absent values to real NULL. Remove the temporary shim in PostScan/PostMapArgs that was bridging the old NOT NULL columns to the *int model fields. Add round-trip persistence tests asserting NULL storage for nil pointers and correct value round-trip for non-nil pointers. * test(e2e): verify isMissing/isPresent partition for nullable fields Add DescribeTable covering bpm, bitdepth, lyrics, and mbz_recording_id: for each field, isMissing + isPresent song counts must equal the total library count, proving the nullable-column SQL is exhaustive and correct. * test(e2e): seed bpm tag so isMissing/isPresent partition is non-trivial * fix(model): omit bitDepth from JSON when absent instead of emitting null * feat(smartplaylist): support isMissing/isPresent on more string fields Enable isMissing/isPresent operators for album, comment, catalognumber, discsubtitle, albumcomment, sorttitle, sortalbum, sortartist, sortalbumartist, and explicitstatus by marking them Nullable in fieldMap. * refactor(smartplaylist): unify missingExpr column logic into one flow Collapse the numeric/string fork in missingExpr into a single empties-driven loop (numeric/boolean fields simply have no empties), and replace the duplicated IsTag/IsRole guard with a three-way switch that expresses the dispatch model once. No SQL semantics change for string fields; numeric/boolean fields now emit a single-element Or/And which squirrel parenthesizes (e.g. `(col IS NULL)` instead of bare `col IS NULL`) — update the affected test expectations accordingly.
176 lines
5.6 KiB
Go
176 lines
5.6 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": {},
|
|
"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}
|
|
}
|
|
}
|
|
}
|