mirror of
https://github.com/navidrome/navidrome.git
synced 2026-06-11 09:08:04 -04:00
* fix(smartplaylist): support isMissing/isPresent operators on ReplayGain fields ReplayGain values are stored in dedicated nullable columns (rg_album_gain, rg_album_peak, rg_track_gain, rg_track_peak) rather than in the media_file.tags JSON blob. The isMissing/isPresent operators previously only supported tag and role fields, causing two failure modes: 1. Using the documented alias names (replaygain_album_gain etc.) from PR #5256: these got registered as JSON tags from mappings.yaml, so isMissing queried json_tree(media_file.tags, '$.replaygain_album_gain') which is always empty -> the playlist matched ALL songs. 2. Using the canonical field names (rgalbumgain etc.): not a tag/role, so SQL generation returned an error. Because refreshSmartPlaylist deletes old tracks before regenerating, the abort left the playlist empty. Fix: add Nullable bool to FieldInfo and mark the four ReplayGain fields. Add static alias entries (replaygain_album_gain -> rgalbumgain etc.) with Numeric+Nullable set; because AddTagNames skips names already in the field map, these static entries take precedence over the mappings.yaml tag registration. missingExpr now emits IS NULL / IS NOT NULL for nullable column fields instead of the json_tree lookup. Fixes #5584 * chore(smartplaylist): address code review feedback - Simplify the isMissing/isPresent unsupported-field error message, removing the internal "nullable fields" jargon - Standardize comments on mappings.yaml (the actual filename) - Clarify the alias precedence comment in the LookupField test
678 lines
21 KiB
Go
678 lines
21 KiB
Go
package persistence
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"maps"
|
|
"reflect"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Masterminds/squirrel"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/model/criteria"
|
|
)
|
|
|
|
type smartPlaylistJoinType int
|
|
|
|
const (
|
|
smartPlaylistJoinNone smartPlaylistJoinType = 0
|
|
smartPlaylistJoinAlbumAnnotation smartPlaylistJoinType = 1 << iota
|
|
smartPlaylistJoinArtistAnnotation
|
|
)
|
|
|
|
func (j smartPlaylistJoinType) has(other smartPlaylistJoinType) bool {
|
|
return j&other != 0
|
|
}
|
|
|
|
type smartPlaylistField struct {
|
|
expr string
|
|
order string
|
|
joinType smartPlaylistJoinType
|
|
}
|
|
|
|
type smartPlaylistCriteria struct {
|
|
criteria.Criteria
|
|
owner model.User
|
|
}
|
|
|
|
func newSmartPlaylistCriteria(c criteria.Criteria, opts ...func(*smartPlaylistCriteria)) smartPlaylistCriteria {
|
|
cSQL := smartPlaylistCriteria{Criteria: c}
|
|
for _, opt := range opts {
|
|
opt(&cSQL)
|
|
}
|
|
return cSQL
|
|
}
|
|
|
|
func withSmartPlaylistOwner(owner model.User) func(*smartPlaylistCriteria) {
|
|
return func(c *smartPlaylistCriteria) {
|
|
c.owner = owner
|
|
}
|
|
}
|
|
|
|
var smartPlaylistFields = map[string]smartPlaylistField{
|
|
"title": {expr: "media_file.title"},
|
|
"album": {expr: "media_file.album"},
|
|
"hascoverart": {expr: "media_file.has_cover_art"},
|
|
"tracknumber": {expr: "media_file.track_number"},
|
|
"discnumber": {expr: "media_file.disc_number"},
|
|
"year": {expr: "media_file.year"},
|
|
"date": {expr: "media_file.date"},
|
|
"originalyear": {expr: "media_file.original_year"},
|
|
"originaldate": {expr: "media_file.original_date"},
|
|
"releaseyear": {expr: "media_file.release_year"},
|
|
"releasedate": {expr: "media_file.release_date"},
|
|
"size": {expr: "media_file.size"},
|
|
"compilation": {expr: "media_file.compilation"},
|
|
"missing": {expr: "media_file.missing"},
|
|
"explicitstatus": {expr: "media_file.explicit_status"},
|
|
"dateadded": {expr: "media_file.created_at"},
|
|
"datemodified": {expr: "media_file.updated_at"},
|
|
"discsubtitle": {expr: "media_file.disc_subtitle"},
|
|
"comment": {expr: "media_file.comment"},
|
|
"lyrics": {expr: "media_file.lyrics"},
|
|
"sorttitle": {expr: "media_file.sort_title"},
|
|
"sortalbum": {expr: "media_file.sort_album_name"},
|
|
"sortartist": {expr: "media_file.sort_artist_name"},
|
|
"sortalbumartist": {expr: "media_file.sort_album_artist_name"},
|
|
"albumcomment": {expr: "media_file.mbz_album_comment"},
|
|
"catalognumber": {expr: "media_file.catalog_num"},
|
|
"filepath": {expr: "media_file.path"},
|
|
"filetype": {expr: "media_file.suffix"},
|
|
"codec": {expr: "media_file.codec"},
|
|
"duration": {expr: "media_file.duration"},
|
|
"bitrate": {expr: "media_file.bit_rate"},
|
|
"bitdepth": {expr: "media_file.bit_depth"},
|
|
"samplerate": {expr: "media_file.sample_rate"},
|
|
"bpm": {expr: "media_file.bpm"},
|
|
"channels": {expr: "media_file.channels"},
|
|
"loved": {expr: "COALESCE(annotation.starred, false)"},
|
|
"dateloved": {expr: "annotation.starred_at"},
|
|
"lastplayed": {expr: "annotation.play_date"},
|
|
"daterated": {expr: "annotation.rated_at"},
|
|
"playcount": {expr: "COALESCE(annotation.play_count, 0)"},
|
|
"rating": {expr: "COALESCE(annotation.rating, 0)"},
|
|
"averagerating": {expr: "media_file.average_rating"},
|
|
"albumrating": {expr: "COALESCE(album_annotation.rating, 0)", joinType: smartPlaylistJoinAlbumAnnotation},
|
|
"albumloved": {expr: "COALESCE(album_annotation.starred, false)", joinType: smartPlaylistJoinAlbumAnnotation},
|
|
"albumplaycount": {expr: "COALESCE(album_annotation.play_count, 0)", joinType: smartPlaylistJoinAlbumAnnotation},
|
|
"albumlastplayed": {expr: "album_annotation.play_date", joinType: smartPlaylistJoinAlbumAnnotation},
|
|
"albumdateloved": {expr: "album_annotation.starred_at", joinType: smartPlaylistJoinAlbumAnnotation},
|
|
"albumdaterated": {expr: "album_annotation.rated_at", joinType: smartPlaylistJoinAlbumAnnotation},
|
|
"artistrating": {expr: "COALESCE(artist_annotation.rating, 0)", joinType: smartPlaylistJoinArtistAnnotation},
|
|
"artistloved": {expr: "COALESCE(artist_annotation.starred, false)", joinType: smartPlaylistJoinArtistAnnotation},
|
|
"artistplaycount": {expr: "COALESCE(artist_annotation.play_count, 0)", joinType: smartPlaylistJoinArtistAnnotation},
|
|
"artistlastplayed": {expr: "artist_annotation.play_date", joinType: smartPlaylistJoinArtistAnnotation},
|
|
"artistdateloved": {expr: "artist_annotation.starred_at", joinType: smartPlaylistJoinArtistAnnotation},
|
|
"artistdaterated": {expr: "artist_annotation.rated_at", joinType: smartPlaylistJoinArtistAnnotation},
|
|
"mbz_album_id": {expr: "media_file.mbz_album_id"},
|
|
"mbz_album_artist_id": {expr: "media_file.mbz_album_artist_id"},
|
|
"mbz_artist_id": {expr: "media_file.mbz_artist_id"},
|
|
"mbz_recording_id": {expr: "media_file.mbz_recording_id"},
|
|
"mbz_release_track_id": {expr: "media_file.mbz_release_track_id"},
|
|
"mbz_release_group_id": {expr: "media_file.mbz_release_group_id"},
|
|
"rgalbumgain": {expr: "media_file.rg_album_gain"},
|
|
"rgalbumpeak": {expr: "media_file.rg_album_peak"},
|
|
"rgtrackgain": {expr: "media_file.rg_track_gain"},
|
|
"rgtrackpeak": {expr: "media_file.rg_track_peak"},
|
|
"library_id": {expr: "media_file.library_id"},
|
|
"random": {order: "random()"},
|
|
}
|
|
|
|
func (c smartPlaylistCriteria) Where() (squirrel.Sqlizer, error) {
|
|
if c.Criteria.Expression == nil {
|
|
return squirrel.Expr("1 = 1"), nil
|
|
}
|
|
return c.exprSQL(c.Criteria.Expression)
|
|
}
|
|
|
|
func (c smartPlaylistCriteria) exprSQL(expr criteria.Expression) (squirrel.Sqlizer, error) {
|
|
switch e := expr.(type) {
|
|
case criteria.All:
|
|
and := squirrel.And{}
|
|
for _, child := range e {
|
|
cond, err := c.exprSQL(child)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
and = append(and, cond)
|
|
}
|
|
return and, nil
|
|
case criteria.Any:
|
|
or := squirrel.Or{}
|
|
for _, child := range e {
|
|
cond, err := c.exprSQL(child)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
or = append(or, cond)
|
|
}
|
|
return mergeJsonConds(or), nil
|
|
case criteria.Is:
|
|
return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer {
|
|
return squirrel.Eq(fields)
|
|
}, false)
|
|
case criteria.IsNot:
|
|
return isNotExpr(e)
|
|
case criteria.Gt:
|
|
return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer {
|
|
return squirrel.Gt(fields)
|
|
}, false)
|
|
case criteria.Lt:
|
|
return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer {
|
|
return squirrel.Lt(fields)
|
|
}, false)
|
|
case criteria.Before:
|
|
return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer {
|
|
return squirrel.Lt(fields)
|
|
}, false)
|
|
case criteria.After:
|
|
return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer {
|
|
return squirrel.Gt(fields)
|
|
}, false)
|
|
case criteria.Contains:
|
|
return likeExpr(e, "%%%v%%", false)
|
|
case criteria.NotContains:
|
|
return likeExpr(e, "%%%v%%", true)
|
|
case criteria.StartsWith:
|
|
return likeExpr(e, "%v%%", false)
|
|
case criteria.EndsWith:
|
|
return likeExpr(e, "%%%v", false)
|
|
case criteria.InTheRange:
|
|
return rangeExpr(e)
|
|
case criteria.InTheLast:
|
|
return periodExpr(e, false)
|
|
case criteria.NotInTheLast:
|
|
return periodExpr(e, true)
|
|
case criteria.InPlaylist:
|
|
return c.inList(e, false)
|
|
case criteria.NotInPlaylist:
|
|
return c.inList(e, true)
|
|
case criteria.IsMissing:
|
|
return missingExpr(e, true)
|
|
case criteria.IsPresent:
|
|
return missingExpr(e, false)
|
|
default:
|
|
return nil, fmt.Errorf("unknown criteria expression type %T", expr)
|
|
}
|
|
}
|
|
|
|
func isNotExpr(values map[string]any) (squirrel.Sqlizer, error) {
|
|
if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) {
|
|
return jsonExpr(info, squirrel.Eq{"value": value}, true), nil
|
|
}
|
|
fields, err := sqlFields(values)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return squirrel.NotEq(fields), nil
|
|
}
|
|
|
|
func missingExpr(values map[string]any, checkAbsence bool) (squirrel.Sqlizer, error) {
|
|
field, value, info, ok := singleField(values)
|
|
if !ok {
|
|
if len(values) != 1 {
|
|
return nil, fmt.Errorf("invalid field in criteria: isMissing/isPresent requires exactly one field")
|
|
}
|
|
return nil, fmt.Errorf("invalid field in criteria: %s", field)
|
|
}
|
|
if !info.IsTag && !info.IsRole && !info.Nullable {
|
|
return nil, fmt.Errorf("isMissing/isPresent operator is not supported for field: %s", field)
|
|
}
|
|
|
|
b, ok := value.(bool)
|
|
if !ok {
|
|
return nil, fmt.Errorf("invalid boolean value for 'missing' expression: %s: %v", field, value)
|
|
}
|
|
negate := checkAbsence == b
|
|
|
|
// Nullable column fields (e.g. ReplayGain) are stored in dedicated columns, not in the tags
|
|
// JSON, so "missing" maps to a NULL check on the column rather than a json_tree lookup.
|
|
if info.Nullable && !info.IsTag && !info.IsRole {
|
|
col, ok := fieldExpr(info.Name())
|
|
if !ok || col == "" {
|
|
return nil, fmt.Errorf("invalid field in criteria: %s", field)
|
|
}
|
|
if negate {
|
|
return squirrel.Eq{col: nil}, nil
|
|
}
|
|
return squirrel.NotEq{col: nil}, nil
|
|
}
|
|
|
|
return jsonExpr(info, nil, negate), nil
|
|
}
|
|
|
|
func mapExpr(values map[string]any, makeCond func(map[string]any) squirrel.Sqlizer, negateJSON bool) (squirrel.Sqlizer, error) {
|
|
if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) {
|
|
return jsonExpr(info, makeCond(map[string]any{"value": value}), negateJSON), nil
|
|
}
|
|
fields, err := sqlFields(values)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return makeCond(fields), nil
|
|
}
|
|
|
|
func likeExpr(values map[string]any, pattern string, negate bool) (squirrel.Sqlizer, error) {
|
|
if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) {
|
|
return jsonExpr(info, squirrel.Like{"value": fmt.Sprintf(pattern, value)}, negate), nil
|
|
}
|
|
fields, err := sqlFields(values)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if negate {
|
|
lk := squirrel.NotLike{}
|
|
for field, value := range fields {
|
|
lk[field] = fmt.Sprintf(pattern, value)
|
|
}
|
|
return lk, nil
|
|
}
|
|
lk := squirrel.Like{}
|
|
for field, value := range fields {
|
|
lk[field] = fmt.Sprintf(pattern, value)
|
|
}
|
|
return lk, nil
|
|
}
|
|
|
|
func rangeExpr(values map[string]any) (squirrel.Sqlizer, error) {
|
|
fields, err := sqlFields(values)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
and := squirrel.And{}
|
|
for field, value := range fields {
|
|
s := reflect.ValueOf(value)
|
|
if s.Kind() != reflect.Slice || s.Len() != 2 {
|
|
return nil, fmt.Errorf("invalid range for 'in' operator: %s", value)
|
|
}
|
|
and = append(and,
|
|
squirrel.GtOrEq{field: s.Index(0).Interface()},
|
|
squirrel.LtOrEq{field: s.Index(1).Interface()},
|
|
)
|
|
}
|
|
return and, nil
|
|
}
|
|
|
|
func periodExpr(values map[string]any, negate bool) (squirrel.Sqlizer, error) {
|
|
fields, err := sqlFields(values)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var field string
|
|
var value any
|
|
for f, v := range fields {
|
|
field, value = f, v
|
|
break
|
|
}
|
|
days, err := strconv.ParseInt(fmt.Sprintf("%v", value), 10, 64)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
firstDate := startOfPeriod(days, time.Now())
|
|
if negate {
|
|
return squirrel.Or{
|
|
squirrel.Lt{field: firstDate},
|
|
squirrel.Eq{field: nil},
|
|
}, nil
|
|
}
|
|
return squirrel.Gt{field: firstDate}, nil
|
|
}
|
|
|
|
func startOfPeriod(numDays int64, from time.Time) string {
|
|
return from.Add(time.Duration(-24*numDays) * time.Hour).Format("2006-01-02")
|
|
}
|
|
|
|
func (c smartPlaylistCriteria) inList(values map[string]any, negate bool) (squirrel.Sqlizer, error) {
|
|
playlistID, ok := values["id"].(string)
|
|
if !ok {
|
|
return nil, errors.New("playlist id not given")
|
|
}
|
|
filters := squirrel.And{squirrel.Eq{"pl.playlist_id": playlistID}}
|
|
if !c.owner.IsAdmin {
|
|
if c.owner.ID == "" {
|
|
filters = append(filters, squirrel.Eq{"playlist.public": 1})
|
|
} else {
|
|
filters = append(filters, squirrel.Or{
|
|
squirrel.Eq{"playlist.public": 1},
|
|
squirrel.Eq{"playlist.owner_id": c.owner.ID},
|
|
})
|
|
}
|
|
}
|
|
subQuery := squirrel.Select("media_file_id").
|
|
From("playlist_tracks pl").
|
|
LeftJoin("playlist on pl.playlist_id = playlist.id").
|
|
Where(filters)
|
|
subSQL, subArgs, err := subQuery.PlaceholderFormat(squirrel.Question).ToSql()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if negate {
|
|
return squirrel.Expr("media_file.id NOT IN ("+subSQL+")", subArgs...), nil
|
|
}
|
|
return squirrel.Expr("media_file.id IN ("+subSQL+")", subArgs...), nil
|
|
}
|
|
|
|
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 tagCond{tag: info.Name(), numeric: info.Numeric, cond: cond, not: negate}
|
|
}
|
|
|
|
type tagCond struct {
|
|
tag string
|
|
numeric bool
|
|
cond squirrel.Sqlizer
|
|
not bool
|
|
}
|
|
|
|
func (e tagCond) ToSql() (string, []any, error) {
|
|
var cond string
|
|
var args []any
|
|
var err error
|
|
if e.cond != nil {
|
|
cond, args, err = e.cond.ToSql()
|
|
if e.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)", e.tag, cond)
|
|
} else {
|
|
cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value')", e.tag)
|
|
}
|
|
if e.not {
|
|
cond = "not " + cond
|
|
}
|
|
return cond, args, err
|
|
}
|
|
|
|
type roleCond struct {
|
|
role string
|
|
cond squirrel.Sqlizer
|
|
not bool
|
|
}
|
|
|
|
func (e roleCond) ToSql() (string, []any, error) {
|
|
var cond string
|
|
var args []any
|
|
if e.cond != nil {
|
|
innerSQL, innerArgs, err := roleCondSQL(e.cond)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
cond = roleExistsSQL(innerSQL)
|
|
args = append([]any{e.role}, innerArgs...)
|
|
} else {
|
|
cond = "exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)"
|
|
args = []any{e.role}
|
|
}
|
|
if e.not {
|
|
cond = "not " + cond
|
|
}
|
|
return cond, args, nil
|
|
}
|
|
|
|
// roleCondSQL extracts SQL from a squirrel condition and rewrites the placeholder column name.
|
|
func roleCondSQL(cond squirrel.Sqlizer) (string, []any, error) {
|
|
sql, args, err := cond.ToSql()
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
return strings.ReplaceAll(sql, "value", "artist.name"), args, nil
|
|
}
|
|
|
|
// roleExistsSQL wraps a condition fragment in the standard role EXISTS subquery.
|
|
func roleExistsSQL(innerCond string) string {
|
|
return fmt.Sprintf("exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id "+
|
|
"where mfa.media_file_id = media_file.id and mfa.role = ? and %s)", innerCond)
|
|
}
|
|
|
|
// jsonCondBatchSize limits how many conditions are ORed inside a single EXISTS subquery
|
|
// to stay within SQLite's expression tree depth limit (max 1000). The EXISTS wrapper
|
|
// consumes ~4 levels; each ORed condition adds 1 level. Empirically, 496 is the maximum.
|
|
const jsonCondBatchSize = 350
|
|
|
|
// mergeJsonConds collapses multiple non-negated roleCond or tagCond entries for the same
|
|
// field within an OR group into batched EXISTS subqueries with the conditions ORed inside.
|
|
// This turns N separate correlated subqueries into ceil(N/batchSize), dramatically
|
|
// improving performance for smart playlists with many patterns.
|
|
func mergeJsonConds(or squirrel.Or) squirrel.Sqlizer {
|
|
type condEntry struct {
|
|
index int
|
|
cond squirrel.Sqlizer
|
|
}
|
|
type group struct {
|
|
entries []condEntry
|
|
isRole bool
|
|
numeric bool
|
|
tag string
|
|
}
|
|
groups := make(map[string]*group)
|
|
for i, s := range or {
|
|
switch c := s.(type) {
|
|
case roleCond:
|
|
if c.not || c.cond == nil {
|
|
continue
|
|
}
|
|
g, exists := groups["role:"+c.role]
|
|
if !exists {
|
|
g = &group{isRole: true}
|
|
groups["role:"+c.role] = g
|
|
}
|
|
g.entries = append(g.entries, condEntry{index: i, cond: c.cond})
|
|
case tagCond:
|
|
if c.not || c.cond == nil {
|
|
continue
|
|
}
|
|
g, exists := groups["tag:"+c.tag]
|
|
if !exists {
|
|
g = &group{tag: c.tag, numeric: c.numeric}
|
|
groups["tag:"+c.tag] = g
|
|
}
|
|
g.entries = append(g.entries, condEntry{index: i, cond: c.cond})
|
|
}
|
|
}
|
|
|
|
merged := false
|
|
remove := make(map[int]bool)
|
|
var additions []squirrel.Sqlizer
|
|
for _, key := range slices.Sorted(maps.Keys(groups)) {
|
|
g := groups[key]
|
|
if len(g.entries) < 2 {
|
|
continue
|
|
}
|
|
merged = true
|
|
for _, e := range g.entries {
|
|
remove[e.index] = true
|
|
}
|
|
conds := make([]squirrel.Sqlizer, len(g.entries))
|
|
for i, e := range g.entries {
|
|
conds[i] = e.cond
|
|
}
|
|
if g.isRole {
|
|
role := key[len("role:"):]
|
|
for batch := range slices.Chunk(conds, jsonCondBatchSize) {
|
|
additions = append(additions, roleCondGroup{role: role, conds: batch})
|
|
}
|
|
} else {
|
|
for batch := range slices.Chunk(conds, jsonCondBatchSize) {
|
|
additions = append(additions, tagCondGroup{tag: g.tag, numeric: g.numeric, conds: batch})
|
|
}
|
|
}
|
|
}
|
|
|
|
if !merged {
|
|
return or
|
|
}
|
|
|
|
result := make(squirrel.Or, 0, len(or)-len(remove)+len(additions))
|
|
for i, s := range or {
|
|
if !remove[i] {
|
|
result = append(result, s)
|
|
}
|
|
}
|
|
result = append(result, additions...)
|
|
return result
|
|
}
|
|
|
|
// roleCondGroup represents multiple role conditions for the same role, merged into
|
|
// a single EXISTS subquery for performance.
|
|
type roleCondGroup struct {
|
|
role string
|
|
conds []squirrel.Sqlizer
|
|
}
|
|
|
|
func (g roleCondGroup) ToSql() (string, []any, error) {
|
|
innerParts := make([]string, 0, len(g.conds))
|
|
allArgs := []any{g.role}
|
|
for _, c := range g.conds {
|
|
part, args, err := roleCondSQL(c)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
innerParts = append(innerParts, part)
|
|
allArgs = append(allArgs, args...)
|
|
}
|
|
cond := roleExistsSQL("(" + strings.Join(innerParts, " OR ") + ")")
|
|
return cond, allArgs, nil
|
|
}
|
|
|
|
// tagCondGroup represents multiple tag conditions for the same tag, merged into
|
|
// a single EXISTS subquery for performance.
|
|
type tagCondGroup struct {
|
|
tag string
|
|
numeric bool
|
|
conds []squirrel.Sqlizer
|
|
}
|
|
|
|
func (g tagCondGroup) ToSql() (string, []any, error) {
|
|
innerParts := make([]string, 0, len(g.conds))
|
|
var allArgs []any
|
|
for _, c := range g.conds {
|
|
part, args, err := c.ToSql()
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
if g.numeric {
|
|
part = strings.ReplaceAll(part, "value", "CAST(value AS REAL)")
|
|
}
|
|
innerParts = append(innerParts, part)
|
|
allArgs = append(allArgs, args...)
|
|
}
|
|
cond := fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and (%s))",
|
|
g.tag, strings.Join(innerParts, " OR "))
|
|
return cond, allArgs, nil
|
|
}
|
|
|
|
func singleField(values map[string]any) (string, any, criteria.FieldInfo, bool) {
|
|
if len(values) != 1 {
|
|
return "", nil, criteria.FieldInfo{}, false
|
|
}
|
|
for field, value := range values {
|
|
info, ok := criteria.LookupField(field)
|
|
return field, value, info, ok
|
|
}
|
|
return "", nil, criteria.FieldInfo{}, false
|
|
}
|
|
|
|
func sqlFields(values map[string]any) (map[string]any, error) {
|
|
fields := make(map[string]any, len(values))
|
|
for field, value := range values {
|
|
info, ok := criteria.LookupField(field)
|
|
if !ok {
|
|
return nil, fmt.Errorf("invalid field in criteria: %s", field)
|
|
}
|
|
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())
|
|
if !ok || sqlField == "" {
|
|
return nil, fmt.Errorf("invalid field in criteria: %s", field)
|
|
}
|
|
fields[sqlField] = value
|
|
}
|
|
return fields, nil
|
|
}
|
|
|
|
func fieldExpr(name string) (string, bool) {
|
|
field, ok := smartPlaylistFields[strings.ToLower(name)]
|
|
return field.expr, ok
|
|
}
|
|
|
|
func fieldJoinType(name string) smartPlaylistJoinType {
|
|
info, ok := criteria.LookupField(name)
|
|
if !ok {
|
|
return smartPlaylistJoinNone
|
|
}
|
|
field, ok := smartPlaylistFields[info.Name()]
|
|
if !ok {
|
|
return smartPlaylistJoinNone
|
|
}
|
|
return field.joinType
|
|
}
|
|
|
|
func (c smartPlaylistCriteria) ExpressionJoins() smartPlaylistJoinType {
|
|
var joins smartPlaylistJoinType
|
|
_ = criteria.Walk(c.Criteria.Expression, func(expr criteria.Expression) error {
|
|
for field := range criteria.Fields(expr) {
|
|
joins |= fieldJoinType(field)
|
|
}
|
|
return nil
|
|
})
|
|
return joins
|
|
}
|
|
|
|
func (c smartPlaylistCriteria) RequiredJoins() smartPlaylistJoinType {
|
|
joins := c.ExpressionJoins()
|
|
for _, name := range c.Criteria.SortFieldNames() {
|
|
joins |= fieldJoinType(name)
|
|
}
|
|
return joins
|
|
}
|
|
|
|
func (c smartPlaylistCriteria) OrderBy() string {
|
|
sortFields := c.Criteria.OrderByFields()
|
|
parts := make([]string, 0, len(sortFields))
|
|
for _, sf := range sortFields {
|
|
mapped, ok := sortExpr(sf.Field)
|
|
if !ok {
|
|
continue
|
|
}
|
|
dir := "asc"
|
|
if sf.Desc {
|
|
dir = "desc"
|
|
}
|
|
parts = append(parts, mapped+" "+dir)
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
func sortExpr(sortField string) (string, bool) {
|
|
info, ok := criteria.LookupField(sortField)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
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'), '')"
|
|
case info.IsRole:
|
|
mapped = "COALESCE(json_extract(media_file.participants, '$." + info.Name() + "[0].name'), '')"
|
|
default:
|
|
field, ok := smartPlaylistFields[info.Name()]
|
|
if !ok || field.expr == "" {
|
|
return "", false
|
|
}
|
|
mapped = field.expr
|
|
}
|
|
if info.Numeric {
|
|
mapped = fmt.Sprintf("CAST(%s AS REAL)", mapped)
|
|
}
|
|
return mapped, true
|
|
}
|