mirror of
https://github.com/navidrome/navidrome.git
synced 2026-04-03 06:41:01 +00:00
* feat(criteria): make album ratings available to smart playlist queries Expose an "albumrating" field mapping to album annotations. Signed-off-by: Valeri Sokolov <ulfurinn@ulfurinn.net> * fix(criteria): use query parameters Signed-off-by: Valeri Sokolov <ulfurinn@ulfurinn.net> * feat: add album and artist annotation fields to smart playlists Extend smart playlists to filter songs by album or artist annotations (rating, loved, play count, last played, date loved, date rated). This adds 12 new fields (6 album, 6 artist) with conditional JOINs that are only added when the criteria or sort references them, avoiding unnecessary query overhead. The album table JOIN is also removed since media_file.album_id can be used directly. --------- Signed-off-by: Valeri Sokolov <ulfurinn@ulfurinn.net> Co-authored-by: Deluan <deluan@navidrome.org>
304 lines
10 KiB
Go
304 lines
10 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)"},
|
|
"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 into a generic map
|
|
var k string
|
|
m := make(map[string]any, rv.Len())
|
|
for _, key := range rv.MapKeys() {
|
|
// Save the key to build the expression, and use the provided keyName as the key
|
|
k = key.String()
|
|
m["value"] = rv.MapIndex(key).Interface()
|
|
break // only one key is expected (and supported)
|
|
}
|
|
|
|
// Clear the original map
|
|
for _, key := range rv.MapKeys() {
|
|
rv.SetMapIndex(key, reflect.Value{})
|
|
}
|
|
|
|
// Write the updated map back into the original variable
|
|
for key, val := range m {
|
|
rv.SetMapIndex(reflect.ValueOf(key), reflect.ValueOf(val))
|
|
}
|
|
|
|
return exprFunc(k, expr, 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}
|
|
}
|
|
}
|
|
}
|