navidrome/model/criteria/operators.go
Deluan Quintão 13c48b38a0
fix(smartplaylists): coerce string booleans in smart playlist rules (#5450)
* fix(criteria): coerce string booleans in smart playlist rules - #4826

When clients (e.g. Feishin) send boolean values as strings ("true"/"false")
in smart playlist JSON rules, the SQL comparison fails because SQLite stores
booleans as 0/1 integers. For example, `COALESCE(annotation.starred, false) = 'true'`
never matches.

This adds a `boolean` flag to mapped fields and coerces string values to
native Go bools in `mapFields`, so squirrel generates correct SQL parameters.

Signed-off-by: mango766 <mango766@users.noreply.github.com>
Signed-off-by: easonysliu <easonysliu@tencent.com>

* fix(criteria): implement boolean string coercion for smart playlist rules

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: mango766 <mango766@users.noreply.github.com>
Signed-off-by: easonysliu <easonysliu@tencent.com>
Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: easonysliu <easonysliu@tencent.com>
2026-05-01 19:21:48 -04:00

206 lines
4.4 KiB
Go

package criteria
import "time"
// Conjunctions need to implement this interface, to allow Criteria to extract child playlist IDs recursively
type conjunction interface {
ChildPlaylistIds() []string
}
type (
All []Expression
And = All
)
func (All) fields() map[string]any { return nil }
func (all All) MarshalJSON() ([]byte, error) {
return marshalConjunction("all", all)
}
func (all All) ChildPlaylistIds() (ids []string) {
return extractPlaylistIds(all)
}
type (
Any []Expression
Or = Any
)
func (Any) fields() map[string]any { return nil }
func (any Any) MarshalJSON() ([]byte, error) {
return marshalConjunction("any", any)
}
func (any Any) ChildPlaylistIds() (ids []string) {
return extractPlaylistIds(any)
}
type Is map[string]any
type Eq = Is
func (is Is) MarshalJSON() ([]byte, error) {
return marshalExpression("is", is)
}
func (is Is) fields() map[string]any { return is }
type IsNot map[string]any
func (isn IsNot) MarshalJSON() ([]byte, error) {
return marshalExpression("isNot", isn)
}
func (isn IsNot) fields() map[string]any { return isn }
type Gt map[string]any
func (gt Gt) MarshalJSON() ([]byte, error) {
return marshalExpression("gt", gt)
}
func (gt Gt) fields() map[string]any { return gt }
type Lt map[string]any
func (lt Lt) MarshalJSON() ([]byte, error) {
return marshalExpression("lt", lt)
}
func (lt Lt) fields() map[string]any { return lt }
type Before map[string]any
func (bf Before) MarshalJSON() ([]byte, error) {
return marshalExpression("before", bf)
}
func (bf Before) fields() map[string]any { return bf }
type After Gt
func (af After) MarshalJSON() ([]byte, error) {
return marshalExpression("after", af)
}
func (af After) fields() map[string]any { return af }
type Contains map[string]any
func (ct Contains) MarshalJSON() ([]byte, error) {
return marshalExpression("contains", ct)
}
func (ct Contains) fields() map[string]any { return ct }
type NotContains map[string]any
func (nct NotContains) MarshalJSON() ([]byte, error) {
return marshalExpression("notContains", nct)
}
func (nct NotContains) fields() map[string]any { return nct }
type StartsWith map[string]any
func (sw StartsWith) MarshalJSON() ([]byte, error) {
return marshalExpression("startsWith", sw)
}
func (sw StartsWith) fields() map[string]any { return sw }
type EndsWith map[string]any
func (ew EndsWith) MarshalJSON() ([]byte, error) {
return marshalExpression("endsWith", ew)
}
func (ew EndsWith) fields() map[string]any { return ew }
type InTheRange map[string]any
func (itr InTheRange) MarshalJSON() ([]byte, error) {
return marshalExpression("inTheRange", itr)
}
func (itr InTheRange) fields() map[string]any { return itr }
type InTheLast map[string]any
func (itl InTheLast) MarshalJSON() ([]byte, error) {
return marshalExpression("inTheLast", itl)
}
func (itl InTheLast) fields() map[string]any { return itl }
type NotInTheLast map[string]any
func (nitl NotInTheLast) MarshalJSON() ([]byte, error) {
return marshalExpression("notInTheLast", nitl)
}
func (nitl NotInTheLast) fields() map[string]any { return nitl }
func startOfPeriod(numDays int64, from time.Time) string {
return from.Add(time.Duration(-24*numDays) * time.Hour).Format("2006-01-02")
}
type InPlaylist map[string]any
func (ipl InPlaylist) MarshalJSON() ([]byte, error) {
return marshalExpression("inPlaylist", ipl)
}
func (ipl InPlaylist) fields() map[string]any { return ipl }
type NotInPlaylist map[string]any
func (nipl NotInPlaylist) MarshalJSON() ([]byte, error) {
return marshalExpression("notInPlaylist", nipl)
}
func (nipl NotInPlaylist) fields() map[string]any { return nipl }
type IsMissing map[string]any
func (im IsMissing) MarshalJSON() ([]byte, error) {
return marshalExpression("isMissing", im)
}
func (im IsMissing) fields() map[string]any { return im }
type IsPresent map[string]any
func (ip IsPresent) MarshalJSON() ([]byte, error) {
return marshalExpression("isPresent", ip)
}
func (ip IsPresent) fields() map[string]any { return ip }
func extractPlaylistIds(inputRule any) (ids []string) {
var id string
var ok bool
switch rule := inputRule.(type) {
case Any:
for _, rules := range rule {
ids = append(ids, extractPlaylistIds(rules)...)
}
case All:
for _, rules := range rule {
ids = append(ids, extractPlaylistIds(rules)...)
}
case InPlaylist:
if id, ok = rule["id"].(string); ok {
ids = append(ids, id)
}
case NotInPlaylist:
if id, ok = rule["id"].(string); ok {
ids = append(ids, id)
}
}
return
}