mirror of
https://github.com/navidrome/navidrome.git
synced 2026-07-09 17:18:45 +00:00
Some checks are pending
Pipeline: Test, Lint, Build / Check Docker configuration (push) Waiting to run
Pipeline: Test, Lint, Build / Lint i18n files (push) Waiting to run
Pipeline: Test, Lint, Build / Test JS code (push) Waiting to run
Pipeline: Test, Lint, Build / Get version info (push) Waiting to run
Pipeline: Test, Lint, Build / Lint Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Test Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Test Go code (Windows) (push) Waiting to run
Pipeline: Test, Lint, Build / Build (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-1 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-2 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-3 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-4 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-5 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-6 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-7 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build Windows installers (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-8 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-9 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-10 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Push to GHCR (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Package/Release (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Blocked by required conditions
* fix(smartplaylist): use annotation index for playcount/rating/loved filters
Annotation-field criteria wrapped the column in COALESCE(col, default) so
missing annotation rows behave as 0/false. COALESCE prevents SQLite from
using the column index, forcing a full media_file scan during smart playlist
materialization - multi-second loads on large libraries, independent of rule
complexity.
Store the raw column plus its default and drop COALESCE when the compared
value cannot match the default; fall back to 'col <op> ? OR col IS NULL' when
the default would match, so never-annotated tracks are still preserved.
Sorting keeps COALESCE to retain deterministic NULL ordering. Result set is
unchanged; the materialize query now seeks the annotation index.
Signed-off-by: Deluan <deluan@deluan.com>
* fix(smartplaylist): keep COALESCE for list-valued annotation comparisons
Hardening from final review: a list value (IN (...)) can't drive the index
and a default-inclusive list has per-element NULL semantics, so route slice
values through COALESCE(col, default) to stay exactly equivalent to the prior
form. Also make the bool-default branch explicit (loved only supports
equality operators) and share the COALESCE rendering via coalesceExpr.
Signed-off-by: Deluan <deluan@deluan.com>
* refactor(smartplaylist): make coalesced() a field method
Thermo-nuclear review follow-up: promote the free coalesceExpr(f) to a
smartPlaylistField.coalesced() method that returns the bare expression when
there is no default. This lets sortExpr call field.coalesced() unconditionally
and drop its 'if coalesceDefault != nil' branch, removing the 'only annotation
fields get coalesced' special case from the sort path. Behavior unchanged.
Signed-off-by: Deluan <deluan@deluan.com>
* fix(smartplaylist): keep COALESCE for LIKE, bool-ordering, and tag ranges
Code review (xhigh) found the index-friendly rewrite did not cover every
operator, breaking result-set equivalence on a few reachable raw-JSON paths:
- LIKE family (contains/startsWith/endsWith/notContains) on annotation fields
used the bare column, so a NULL column never matched and missing-annotation
rows were dropped.
- Ordering comparators (gt/lt/...) on bool fields (loved) were decided as
equality, wrongly including never-annotated rows.
- InTheRange on a numeric tag split into two independent json_tree EXISTS,
letting different tag values satisfy each bound.
Centralize the decision in annotationCond via bareNullInclusion: emit the
index-friendly bare form only for scalar values under an exactly-orderable
comparator, otherwise fall back to the COALESCE form (always equivalent to the
original). Route LIKE through coalesced(); reject tag/role ranges. Replace the
local toFloat/toBool with spf13/cast (fixes unhandled numeric types and string
bool forms), and drop the redundant LookupField + double reflect.TypeOf.
A 17-case brute-force check confirms row-set equivalence to the prior
COALESCE form across all operators including the fixed LIKE/bool cases.
Signed-off-by: Deluan <deluan@deluan.com>
* fix(smartplaylist): keep COALESCE for list values on bool annotation fields
Second review found the bareNullInclusion bool branch missed the non-scalar
guard the numeric branch has: a list value on loved/albumloved/artistloved
(e.g. {"is":{"loved":[true]}}) coerced through toBool (which swallowed the
cast error) to false, emitting the bare/OR-IS-NULL form and wrongly including
never-annotated rows. Make toBool return (value, ok) like toFloat and bail to
COALESCE when the value isn't a scalar bool. Also add the missing test for the
tag/role range rejection. A 21-case brute-force confirms row-set equivalence to
the original COALESCE form across every operator, including the bool/numeric
list paths.
Signed-off-by: Deluan <deluan@deluan.com>
* refactor(smartplaylist): drop spf13/cast for stdlib value coercion
The value coercion only sees the handful of types criteria produces (int,
float64, string from JSON; bool already normalized at unmarshal), so cast's
broad conversion isn't needed. Use small explicit type switches over strconv
instead, keeping the string fallback (ParseFloat/ParseBool) that closes the
'1'/'t' gap. No dependency change — cast returns to indirect.
Signed-off-by: Deluan <deluan@deluan.com>
* refactor(smartplaylist): share bool coercion via criteria.ToBool
normalizeBoolValue (unmarshal-time) and the persistence bool guard both parsed
bool-ish values independently. Extract the shared logic into an exported
criteria.ToBool(any) (bool, ok): normalizeBoolValue delegates to it (behavior
unchanged), and the persistence layer reuses it via its existing model/criteria
import instead of a local helper. No behavior change.
Signed-off-by: Deluan <deluan@deluan.com>
* refactor(smartplaylist): trim sqlLiteral and dedup rationale comments
/simplify cleanup: fmt %v already renders bool defaults as false/true, so drop
sqlLiteral's redundant bool branch. Consolidate the COALESCE-vs-index rationale
to the smartPlaylistField comment instead of repeating it across annotationCond
and the struct. No behavior change.
Signed-off-by: Deluan <deluan@deluan.com>
* fix(smartplaylist): address review feedback on multi-field maps and *any
From the PR bot reviews:
- sqlFields now uses the field's coalesced() form, so annotation fields in a
multi-field operator map (Is/Gt/Contains with >1 key) keep COALESCE and don't
silently drop never-annotated rows. Covers both the comparison and LIKE
fallback paths. (Gemini high, Copilot)
- Replace coalesceDefault *any with a plain any (0/false are non-nil
interfaces, so nil still means 'no default'); drop the coalesce() boxing
helper and the pointer indirection. (Gemini)
- Give rangeExpr clear, range-specific errors for the multi-field and malformed
-pair cases instead of an empty-field / 'in operator' message. (Copilot)
Adds tests for the multi-field COALESCE behavior and the new range errors.
Signed-off-by: Deluan <deluan@deluan.com>
* Revert multi-field COALESCE handling (YAGNI)
The multi-field operator map case the bots flagged is unreachable: marshalExpression
rejects any operator map with more than one field, so a multi-field map can never be
persisted or loaded. Revert the sqlFields change and its tests rather than harden a
code path no supported input can reach. Keep the two reachable improvements from the
review: coalesceDefault any (not *any), and the clearer malformed-range error.
Signed-off-by: Deluan <deluan@deluan.com>
* refactor(persistence): model comparator as a behavior-carrying struct
The smart-playlist comparator was a bare string alias, forcing two parallel
switches over the same six operators: squirrelCmp mapped each to its squirrel
constructor, and bareNullInclusion restated each as a float predicate. Adding
or changing an operator meant editing both in sync.
Make comparator a struct that bundles those facts per operator (the squirrel
builder, the operator as a float predicate, and whether it's an ordering op).
Both switches collapse: squirrelCmp is deleted in favor of cmp.build, and
bareNullInclusion's numeric switch becomes a single cmp.satisfy call. Generated
SQL is unchanged, as the existing table-driven tests confirm.
* docs(smartplaylist): trim comments that restate the code
Remove or tighten comments that describe what the code already says (likeCond and
comparisonExpr doc lines, redundant clauses in annotationField/coalesced/ToBool/
normalizeBoolValue). Keep the comments that explain non-obvious rationale: the
COALESCE-vs-index tradeoff, the bareNullInclusion/annotationCond contracts, and the
why-we-fall-back notes.
* docs(smartplaylist): collapse coalesceDefault comment to one line
The field's six-line block duplicated the COALESCE-vs-index rationale that already
lives on annotationCond. Reduce it to a one-line description plus a pointer there.
---------
Signed-off-by: Deluan <deluan@deluan.com>
846 lines
29 KiB
Go
846 lines
29 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)
|
|
coalesceDefault any // missing-row default for a nullable annotation column; nil = none. See annotationCond.
|
|
}
|
|
|
|
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: "annotation.starred", coalesceDefault: false},
|
|
"dateloved": {expr: "annotation.starred_at"},
|
|
"lastplayed": {expr: "annotation.play_date"},
|
|
"daterated": {expr: "annotation.rated_at"},
|
|
"playcount": {expr: "annotation.play_count", coalesceDefault: 0},
|
|
"rating": {expr: "annotation.rating", coalesceDefault: 0},
|
|
"averagerating": {expr: "media_file.average_rating"},
|
|
"albumrating": {expr: "album_annotation.rating", coalesceDefault: 0, joinType: smartPlaylistJoinAlbumAnnotation},
|
|
"albumloved": {expr: "album_annotation.starred", coalesceDefault: false, joinType: smartPlaylistJoinAlbumAnnotation},
|
|
"albumplaycount": {expr: "album_annotation.play_count", coalesceDefault: 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: "artist_annotation.rating", coalesceDefault: 0, joinType: smartPlaylistJoinArtistAnnotation},
|
|
"artistloved": {expr: "artist_annotation.starred", coalesceDefault: false, joinType: smartPlaylistJoinArtistAnnotation},
|
|
"artistplaycount": {expr: "artist_annotation.play_count", coalesceDefault: 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 mergeNegatedJsonConds(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 comparisonExpr(e, cmpEq)
|
|
case criteria.IsNot:
|
|
return isNotExpr(e)
|
|
case criteria.Gt:
|
|
return comparisonExpr(e, cmpGt)
|
|
case criteria.Lt:
|
|
return comparisonExpr(e, cmpLt)
|
|
case criteria.Before:
|
|
return comparisonExpr(e, cmpLt)
|
|
case criteria.After:
|
|
return comparisonExpr(e, cmpGt)
|
|
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
|
|
}
|
|
return comparisonExpr(values, cmpNe)
|
|
}
|
|
|
|
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 likeExpr(values map[string]any, pattern string, negate bool) (squirrel.Sqlizer, error) {
|
|
if _, value, info, ok := singleField(values); ok {
|
|
if info.IsTag || info.IsRole {
|
|
return jsonExpr(info, squirrel.Like{"value": fmt.Sprintf(pattern, value)}, negate), nil
|
|
}
|
|
// LIKE can't use the column index, so annotation fields keep the COALESCE form: a NULL
|
|
// column never matches LIKE, which would silently drop missing-annotation rows the original
|
|
// COALESCE form included.
|
|
if f, isAnnotation := annotationField(info); isAnnotation {
|
|
return likeCond(f.coalesced(), 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 likeCond(col, pattern string, negate bool) squirrel.Sqlizer {
|
|
if negate {
|
|
return squirrel.NotLike{col: pattern}
|
|
}
|
|
return squirrel.Like{col: pattern}
|
|
}
|
|
|
|
func rangeExpr(values map[string]any) (squirrel.Sqlizer, error) {
|
|
field, value, info, ok := singleField(values)
|
|
if !ok {
|
|
return nil, fmt.Errorf("invalid field in criteria: %s", field)
|
|
}
|
|
if info.IsTag || info.IsRole {
|
|
// Tags/roles are multi-valued JSON, so splitting a range into two independent EXISTS
|
|
// subqueries would let different values satisfy each bound. Ranges are unsupported there.
|
|
return nil, fmt.Errorf("range operator not supported for tag/role field: %s", field)
|
|
}
|
|
s := reflect.ValueOf(value)
|
|
if s.Kind() != reflect.Slice || s.Len() != 2 {
|
|
return nil, fmt.Errorf("range criteria for %q must be a [min, max] pair, got: %v", field, value)
|
|
}
|
|
low, err := comparisonExpr(map[string]any{field: s.Index(0).Interface()}, cmpGe)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
high, err := comparisonExpr(map[string]any{field: s.Index(1).Interface()}, cmpLe)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return squirrel.And{low, high}, 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 {
|
|
if merged, ok := mergeSameFieldConds(or, false); ok {
|
|
return squirrel.Or(merged)
|
|
}
|
|
return or
|
|
}
|
|
|
|
// mergeNegatedJsonConds is the AND-group counterpart to mergeJsonConds, merging negated
|
|
// conditions. By De Morgan, "NOT EXISTS(X) AND NOT EXISTS(Y)" == "NOT EXISTS(X OR Y)".
|
|
func mergeNegatedJsonConds(and squirrel.And) squirrel.Sqlizer {
|
|
if merged, ok := mergeSameFieldConds(and, true); ok {
|
|
return squirrel.And(merged)
|
|
}
|
|
return and
|
|
}
|
|
|
|
// mergeSameFieldConds groups roleCond/tagCond entries that share a field and the requested
|
|
// polarity, replacing each group of 2+ with batched roleCondGroup/tagCondGroup subqueries.
|
|
// Returns the rewritten conditions and whether any merge happened.
|
|
func mergeSameFieldConds(conds []squirrel.Sqlizer, negated bool) ([]squirrel.Sqlizer, bool) {
|
|
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 conds {
|
|
switch c := s.(type) {
|
|
case roleCond:
|
|
if c.not != negated || 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 != negated || 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})
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
batchConds := make([]squirrel.Sqlizer, len(g.entries))
|
|
for i, e := range g.entries {
|
|
remove[e.index] = true
|
|
batchConds[i] = e.cond
|
|
}
|
|
if g.isRole {
|
|
role := key[len("role:"):]
|
|
for batch := range slices.Chunk(batchConds, jsonCondBatchSize) {
|
|
additions = append(additions, roleCondGroup{role: role, conds: batch, not: negated})
|
|
}
|
|
} else {
|
|
for batch := range slices.Chunk(batchConds, jsonCondBatchSize) {
|
|
additions = append(additions, tagCondGroup{tag: g.tag, numeric: g.numeric, conds: batch, not: negated})
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(remove) == 0 {
|
|
return conds, false
|
|
}
|
|
|
|
result := make([]squirrel.Sqlizer, 0, len(conds)-len(remove)+len(additions))
|
|
for i, s := range conds {
|
|
if !remove[i] {
|
|
result = append(result, s)
|
|
}
|
|
}
|
|
return append(result, additions...), true
|
|
}
|
|
|
|
// roleCondGroup represents multiple role conditions for the same role, merged into a single
|
|
// (optionally negated) EXISTS subquery for performance.
|
|
type roleCondGroup struct {
|
|
role string
|
|
conds []squirrel.Sqlizer
|
|
not bool
|
|
}
|
|
|
|
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 ") + ")")
|
|
if g.not {
|
|
cond = "not " + cond
|
|
}
|
|
return cond, allArgs, nil
|
|
}
|
|
|
|
// tagCondGroup represents multiple tag conditions for the same tag, merged into a single
|
|
// (optionally negated) EXISTS subquery for performance.
|
|
type tagCondGroup struct {
|
|
tag string
|
|
numeric bool
|
|
conds []squirrel.Sqlizer
|
|
not bool
|
|
}
|
|
|
|
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 "))
|
|
if g.not {
|
|
cond = "not " + cond
|
|
}
|
|
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
|
|
}
|
|
|
|
// comparator is a scalar SQL comparison operator used by smart playlist criteria.
|
|
type comparator struct {
|
|
build func(map[string]any) squirrel.Sqlizer
|
|
satisfy func(a, b float64) bool // the operator as a predicate, to reason about a column's COALESCE default
|
|
// ordering is false only for = and <>, the only operators with a clean bare form over bool columns.
|
|
ordering bool
|
|
}
|
|
|
|
var (
|
|
cmpEq = comparator{build: func(f map[string]any) squirrel.Sqlizer { return squirrel.Eq(f) }, satisfy: func(a, b float64) bool { return a == b }}
|
|
cmpNe = comparator{build: func(f map[string]any) squirrel.Sqlizer { return squirrel.NotEq(f) }, satisfy: func(a, b float64) bool { return a != b }}
|
|
cmpGt = comparator{build: func(f map[string]any) squirrel.Sqlizer { return squirrel.Gt(f) }, satisfy: func(a, b float64) bool { return a > b }, ordering: true}
|
|
cmpGe = comparator{build: func(f map[string]any) squirrel.Sqlizer { return squirrel.GtOrEq(f) }, satisfy: func(a, b float64) bool { return a >= b }, ordering: true}
|
|
cmpLt = comparator{build: func(f map[string]any) squirrel.Sqlizer { return squirrel.Lt(f) }, satisfy: func(a, b float64) bool { return a < b }, ordering: true}
|
|
cmpLe = comparator{build: func(f map[string]any) squirrel.Sqlizer { return squirrel.LtOrEq(f) }, satisfy: func(a, b float64) bool { return a <= b }, ordering: true}
|
|
)
|
|
|
|
// annotationField returns the field definition only for nullable annotation columns that have a
|
|
// COALESCE default (playcount, rating, loved). Date annotation columns (lastplayed, dateloved, ...)
|
|
// have no default and return ok=false.
|
|
func annotationField(info criteria.FieldInfo) (smartPlaylistField, bool) {
|
|
f, ok := smartPlaylistFields[info.Name()]
|
|
if !ok || f.coalesceDefault == nil {
|
|
return smartPlaylistField{}, false
|
|
}
|
|
return f, true
|
|
}
|
|
|
|
// coalesced wraps the field in COALESCE(col, default) (bare expression if it has no default). Used
|
|
// where index-friendliness does not apply (ORDER BY, list comparisons) and the missing-row-as-default
|
|
// semantics must be kept.
|
|
func (f smartPlaylistField) coalesced() string {
|
|
if f.coalesceDefault == nil {
|
|
return f.expr
|
|
}
|
|
return fmt.Sprintf("COALESCE(%s, %s)", f.expr, sqlLiteral(f.coalesceDefault))
|
|
}
|
|
|
|
func comparisonExpr(values map[string]any, cmp comparator) (squirrel.Sqlizer, error) {
|
|
if _, value, info, ok := singleField(values); ok {
|
|
if info.IsTag || info.IsRole {
|
|
return jsonExpr(info, cmp.build(map[string]any{"value": value}), false), nil
|
|
}
|
|
if f, isAnnotation := annotationField(info); isAnnotation {
|
|
return annotationCond(f, cmp, value), nil
|
|
}
|
|
}
|
|
fields, err := sqlFields(values)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return cmp.build(fields), nil
|
|
}
|
|
|
|
// annotationCond builds a comparison against a nullable annotation column (see smartPlaylistField
|
|
// for why). When bareNullInclusion can reason about the comparison exactly it emits the
|
|
// index-friendly bare `col <cmp> ?`, adding `OR col IS NULL` only when the default would match;
|
|
// otherwise it falls back to the COALESCE form, which can't use the index but is always equivalent.
|
|
func annotationCond(f smartPlaylistField, cmp comparator, value any) squirrel.Sqlizer {
|
|
wrapNull, ok := bareNullInclusion(f.coalesceDefault, cmp, value)
|
|
if !ok {
|
|
return cmp.build(map[string]any{f.coalesced(): value})
|
|
}
|
|
base := cmp.build(map[string]any{f.expr: value})
|
|
if !wrapNull {
|
|
return base
|
|
}
|
|
// The default satisfies the predicate, so missing rows (NULL) must be included. All comparators
|
|
// (including <>) evaluate to false against NULL, so an explicit IS NULL restores them.
|
|
return squirrel.Or{base, squirrel.Eq{f.expr: nil}}
|
|
}
|
|
|
|
// bareNullInclusion decides whether the index-friendly bare form is safe for this comparison and,
|
|
// if so, whether missing (NULL) rows must be re-included via `OR col IS NULL`. It returns ok=false
|
|
// when the bare form can't be proven equivalent to COALESCE(col, default) <cmp> ? — i.e. the value
|
|
// isn't a scalar the comparator can order exactly (lists, bool ordering, unparseable values) — in
|
|
// which case the caller keeps the COALESCE form. When ok=true, wrapNull is true iff the default
|
|
// value itself satisfies the predicate (so missing rows would match and must be preserved).
|
|
func bareNullInclusion(defaultVal any, cmp comparator, value any) (wrapNull, ok bool) {
|
|
if b, isBool := defaultVal.(bool); isBool {
|
|
// Bool columns have an exact bare form only for equality; ordering operators fall back to
|
|
// COALESCE. Mapping both bools to 0/1 lets cmp.satisfy reuse the numeric eq/ne predicate.
|
|
if cmp.ordering {
|
|
return false, false
|
|
}
|
|
v, okV := criteria.ToBool(value)
|
|
if !okV {
|
|
return false, false
|
|
}
|
|
return cmp.satisfy(boolToFloat(b), boolToFloat(v)), true
|
|
}
|
|
d, okD := toFloat(defaultVal)
|
|
v, okV := toFloat(value)
|
|
if !okD || !okV {
|
|
// Non-scalar or unparseable value: no exact bare form, keep COALESCE.
|
|
return false, false
|
|
}
|
|
return cmp.satisfy(d, v), true
|
|
}
|
|
|
|
func boolToFloat(b bool) float64 {
|
|
if b {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// toFloat coerces a scalar criteria value to float64. Criteria values come from JSON (float64,
|
|
// string) or are built in Go (int, float64), so only those types are handled; anything else —
|
|
// including a slice or an unparseable string — reports ok=false so the caller keeps the COALESCE
|
|
// form instead of an index-friendly bare comparison.
|
|
func toFloat(v any) (float64, bool) {
|
|
switch n := v.(type) {
|
|
case float64:
|
|
return n, true
|
|
case int:
|
|
return float64(n), true
|
|
case int64:
|
|
return float64(n), true
|
|
case string:
|
|
f, err := strconv.ParseFloat(n, 64)
|
|
return f, err == nil
|
|
default:
|
|
return 0, false
|
|
}
|
|
}
|
|
|
|
// sqlLiteral renders an annotation field's COALESCE default (0 or false) as a SQL literal for ORDER
|
|
// BY. %v renders both bool and numeric defaults correctly (false/true, 0).
|
|
func sqlLiteral(v any) string {
|
|
return fmt.Sprintf("%v", v)
|
|
}
|
|
|
|
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
|
|
}
|
|
// Sorting keeps the COALESCE default so missing-annotation rows sort as that default
|
|
// (filtering drops COALESCE for index use, but ORDER BY has no index to preserve here).
|
|
mapped = field.coalesced()
|
|
}
|
|
if info.Numeric {
|
|
mapped = fmt.Sprintf("CAST(%s AS REAL)", mapped)
|
|
}
|
|
return mapped, true
|
|
}
|