mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-14 22:28:41 +01: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.
692 lines
22 KiB
Go
692 lines
22 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
|
|
emptyValues []string // additional values that encode "missing" for string columns (e.g. '[]' for lyrics)
|
|
}
|
|
|
|
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", emptyValues: []string{"[]"}},
|
|
"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)
|
|
}
|
|
b, ok := value.(bool)
|
|
if !ok {
|
|
return nil, fmt.Errorf("invalid boolean value for 'missing' expression: %s: %v", field, value)
|
|
}
|
|
negate := checkAbsence == b
|
|
|
|
switch {
|
|
case info.IsTag || info.IsRole:
|
|
return jsonExpr(info, nil, negate), nil
|
|
case info.Nullable:
|
|
// Nullable column fields are stored in dedicated columns, not in the tags JSON, so
|
|
// "missing" maps to a column check rather than a json_tree lookup. Numeric/boolean
|
|
// columns (e.g. ReplayGain, BPM) encode absence as NULL only; string columns (e.g.
|
|
// mbz_* IDs, lyrics) additionally treat empty string — and any field-specific empty
|
|
// encodings (e.g. '[]' for lyrics) — as missing. The unified flow below handles both:
|
|
// numeric/boolean fields simply have no empties, so the loops are no-ops.
|
|
f, ok := smartPlaylistFields[info.Name()]
|
|
if !ok || f.expr == "" {
|
|
return nil, fmt.Errorf("invalid field in criteria: %s", field)
|
|
}
|
|
col := f.expr
|
|
var empties []string
|
|
if !info.Numeric && !info.Boolean {
|
|
empties = append([]string{""}, f.emptyValues...)
|
|
}
|
|
missing := squirrel.Or{squirrel.Eq{col: nil}}
|
|
present := squirrel.And{squirrel.NotEq{col: nil}}
|
|
for _, e := range empties {
|
|
missing = append(missing, squirrel.Eq{col: e})
|
|
present = append(present, squirrel.NotEq{col: e})
|
|
}
|
|
if negate {
|
|
return missing, nil
|
|
}
|
|
return present, nil
|
|
default:
|
|
return nil, fmt.Errorf("isMissing/isPresent operator is not supported for field: %s", field)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|