11e4aaed1b
* feat(playlists): add percentage-based limits to smart playlists Add a new `limitPercent` JSON field to Criteria that allows smart playlist limits to be expressed as a percentage of matching tracks rather than a fixed number. For example, a playlist matching 450 songs with a 10% limit returns 45 songs, scaling dynamically as the library grows. When `limitPercent` is set, refreshSmartPlaylist runs a COUNT query first to determine the total matching tracks, then resolves the percentage to an absolute LIMIT before executing the main query. The fixed `limit` field takes precedence when both are set. Values are clamped to [0, 100] during JSON unmarshaling. No database migration is needed since rules are stored as a JSON string. * fix(criteria): validate percentage limit range in IsPercentageLimit method Signed-off-by: Deluan <deluan@navidrome.org> * fix(criteria): ensure idempotency of ToSql method for expressions Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
302 lines
11 KiB
Go
302 lines
11 KiB
Go
package criteria
|
|
|
|
import (
|
|
"fmt"
|
|
"reflect"
|
|
"strings"
|
|
|
|
"github.com/Masterminds/squirrel"
|
|
"github.com/navidrome/navidrome/log"
|
|
)
|
|
|
|
// JoinType is a bitmask indicating which additional JOINs are needed by a smart playlist expression.
|
|
type JoinType int
|
|
|
|
const (
|
|
JoinNone JoinType = 0
|
|
JoinAlbumAnnotation JoinType = 1 << iota
|
|
JoinArtistAnnotation
|
|
)
|
|
|
|
// Has returns true if j contains all bits in other.
|
|
func (j JoinType) Has(other JoinType) bool { return j&other != 0 }
|
|
|
|
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"},
|
|
"explicitstatus": {field: "media_file.explicit_status"},
|
|
"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"},
|
|
"daterated": {field: "annotation.rated_at"},
|
|
"playcount": {field: "COALESCE(annotation.play_count, 0)"},
|
|
"rating": {field: "COALESCE(annotation.rating, 0)"},
|
|
"averagerating": {field: "media_file.average_rating", numeric: true},
|
|
"albumrating": {field: "COALESCE(album_annotation.rating, 0)", joinType: JoinAlbumAnnotation},
|
|
"albumloved": {field: "COALESCE(album_annotation.starred, false)", joinType: JoinAlbumAnnotation},
|
|
"albumplaycount": {field: "COALESCE(album_annotation.play_count, 0)", joinType: JoinAlbumAnnotation},
|
|
"albumlastplayed": {field: "album_annotation.play_date", joinType: JoinAlbumAnnotation},
|
|
"albumdateloved": {field: "album_annotation.starred_at", joinType: JoinAlbumAnnotation},
|
|
"albumdaterated": {field: "album_annotation.rated_at", joinType: JoinAlbumAnnotation},
|
|
|
|
"artistrating": {field: "COALESCE(artist_annotation.rating, 0)", joinType: JoinArtistAnnotation},
|
|
"artistloved": {field: "COALESCE(artist_annotation.starred, false)", joinType: JoinArtistAnnotation},
|
|
"artistplaycount": {field: "COALESCE(artist_annotation.play_count, 0)", joinType: JoinArtistAnnotation},
|
|
"artistlastplayed": {field: "artist_annotation.play_date", joinType: JoinArtistAnnotation},
|
|
"artistdateloved": {field: "artist_annotation.starred_at", joinType: JoinArtistAnnotation},
|
|
"artistdaterated": {field: "artist_annotation.rated_at", joinType: JoinArtistAnnotation},
|
|
|
|
"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
|
|
joinType JoinType // which additional JOINs this field requires
|
|
}
|
|
|
|
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 the field name and value, then build a new map keyed by "value"
|
|
// for the inner condition. The original map is left untouched so that
|
|
// ToSql can be called multiple times without corruption.
|
|
var k string
|
|
var v any
|
|
for _, key := range rv.MapKeys() {
|
|
k = key.String()
|
|
v = rv.MapIndex(key).Interface()
|
|
break // only one key is expected (and supported)
|
|
}
|
|
|
|
// Create a new map-based expression with "value" as the key, matching the
|
|
// column name inside json_tree subqueries.
|
|
newMap := reflect.MakeMap(rv.Type())
|
|
newMap.SetMapIndex(reflect.ValueOf("value"), reflect.ValueOf(v))
|
|
newExpr := newMap.Interface().(squirrel.Sqlizer)
|
|
|
|
return exprFunc(k, newExpr, 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(media_file.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(media_file.participants, '$.%s') where key='name' and %s)`,
|
|
e.role, cond)
|
|
if e.not {
|
|
cond = "not " + cond
|
|
}
|
|
return cond, args, err
|
|
}
|
|
|
|
// fieldJoinType returns the JoinType for a given field name (case-insensitive).
|
|
func fieldJoinType(name string) JoinType {
|
|
if f, ok := fieldMap[strings.ToLower(name)]; ok {
|
|
return f.joinType
|
|
}
|
|
return JoinNone
|
|
}
|
|
|
|
// extractJoinTypes walks an expression tree and collects all required JoinType flags.
|
|
func extractJoinTypes(expr any) JoinType {
|
|
result := JoinNone
|
|
switch e := expr.(type) {
|
|
case All:
|
|
for _, sub := range e {
|
|
result |= extractJoinTypes(sub)
|
|
}
|
|
case Any:
|
|
for _, sub := range e {
|
|
result |= extractJoinTypes(sub)
|
|
}
|
|
default:
|
|
// Leaf expression: use reflection to check if it's a map with field names
|
|
rv := reflect.ValueOf(expr)
|
|
if rv.Kind() == reflect.Map && rv.Type().Key().Kind() == reflect.String {
|
|
for _, key := range rv.MapKeys() {
|
|
result |= fieldJoinType(key.String())
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// 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}
|
|
}
|
|
}
|
|
}
|