Upstream navidrome/navidrome state of the touched files before b3c16e5fa1

This commit is contained in:
upstream committed 2026-10-14 19:07:59 +02:00
commit 46e0546b86
4 files changed
+1925

No files matched your search

+426
View File
@@ -0,0 +1,426 @@
package criteria
import (
"bytes"
"encoding/json"
"time"
"github.com/google/uuid"
. "github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
)
var _ = Describe("Criteria", func() {
var goObj Criteria
var jsonObj string
Context("with a complex criteria", func() {
BeforeEach(func() {
goObj = Criteria{
Expression: All{
Contains{"title": "love"},
NotContains{"title": "hate"},
Any{
IsNot{"artist": "u2"},
Is{"album": "best of"},
},
All{
StartsWith{"comment": "this"},
InTheRange{"year": []int{1980, 1990}},
IsNot{"genre": "Rock"},
Gt{"albumrating": 3},
},
},
Sort: "title",
Order: "asc",
Limit: 20,
Offset: 10,
}
var b bytes.Buffer
err := json.Compact(&b, []byte(`
{
"all": [
{ "contains": {"title": "love"} },
{ "notContains": {"title": "hate"} },
{ "any": [
{ "isNot": {"artist": "u2"} },
{ "is": {"album": "best of"} }
]
},
{ "all": [
{ "startsWith": {"comment": "this"} },
{ "inTheRange": {"year":[1980,1990]} },
{ "isNot": { "genre": "Rock" }},
{ "gt": { "albumrating": 3 } }
]
}
],
"sort": "title",
"order": "asc",
"limit": 20,
"offset": 10
}
`))
if err != nil {
panic(err)
}
jsonObj = b.String()
})
It("marshals to JSON", func() {
j, err := json.Marshal(goObj)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(string(j)).To(gomega.Equal(jsonObj))
})
It("is reversible to/from JSON", func() {
var newObj Criteria
err := json.Unmarshal([]byte(jsonObj), &newObj)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
j, err := json.Marshal(newObj)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(string(j)).To(gomega.Equal(jsonObj))
})
})
Context("with both top-level 'all' and 'any'", func() {
It("returns an error instead of silently dropping one of the groups", func() {
jsonStr := `{"any":[{"inPlaylist":{"path":"a.nsp"}}],"all":[{"notInPlaylist":{"path":"b.nsp"}}]}`
var c Criteria
err := json.Unmarshal([]byte(jsonStr), &c)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(err.Error()).To(gomega.And(gomega.ContainSubstring("all"), gomega.ContainSubstring("any")))
})
DescribeTable("rejects both keys even when one group is present but empty",
func(jsonStr string) {
var c Criteria
err := json.Unmarshal([]byte(jsonStr), &c)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(err.Error()).To(gomega.And(gomega.ContainSubstring("all"), gomega.ContainSubstring("any")))
},
Entry("empty any", `{"any":[],"all":[{"is":{"loved":true}}]}`),
Entry("empty all", `{"all":[],"any":[{"is":{"loved":true}}]}`),
Entry("null any", `{"any":null,"all":[{"is":{"loved":true}}]}`),
)
})
Describe("LimitPercent", func() {
Describe("JSON round-trip", func() {
It("marshals and unmarshals limitPercent", func() {
goObj := Criteria{
Expression: All{Contains{"title": "love"}},
Sort: "title",
Order: "asc",
LimitPercent: 10,
}
j, err := json.Marshal(goObj)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(string(j)).To(gomega.ContainSubstring(`"limitPercent":10`))
gomega.Expect(string(j)).ToNot(gomega.ContainSubstring(`"limit"`))
var newObj Criteria
err = json.Unmarshal(j, &newObj)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(newObj.LimitPercent).To(gomega.Equal(10))
gomega.Expect(newObj.Limit).To(gomega.Equal(0))
})
It("does not include limitPercent when zero", func() {
goObj := Criteria{
Expression: All{Contains{"title": "love"}},
Limit: 50,
}
j, err := json.Marshal(goObj)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(string(j)).To(gomega.ContainSubstring(`"limit":50`))
gomega.Expect(string(j)).ToNot(gomega.ContainSubstring(`limitPercent`))
})
It("backward compatible: JSON with only limit still works", func() {
jsonStr := `{"all":[{"contains":{"title":"love"}}],"limit":20}`
var c Criteria
err := json.Unmarshal([]byte(jsonStr), &c)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(c.Limit).To(gomega.Equal(20))
gomega.Expect(c.LimitPercent).To(gomega.Equal(0))
})
})
Describe("UnmarshalJSON clamping", func() {
It("clamps values above 100 to 100", func() {
jsonStr := `{"all":[{"contains":{"title":"love"}}],"limitPercent":150}`
var c Criteria
err := json.Unmarshal([]byte(jsonStr), &c)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(c.LimitPercent).To(gomega.Equal(100))
})
It("clamps negative values to 0", func() {
jsonStr := `{"all":[{"contains":{"title":"love"}}],"limitPercent":-5}`
var c Criteria
err := json.Unmarshal([]byte(jsonStr), &c)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(c.LimitPercent).To(gomega.Equal(0))
})
})
Describe("EffectiveLimit", func() {
It("returns fixed limit when Limit is set", func() {
c := Criteria{Limit: 50, LimitPercent: 10}
gomega.Expect(c.EffectiveLimit(1000)).To(gomega.Equal(50))
})
It("returns percentage-based limit", func() {
c := Criteria{LimitPercent: 10}
gomega.Expect(c.EffectiveLimit(450)).To(gomega.Equal(45))
})
It("returns minimum 1 when totalCount > 0 and percentage rounds to 0", func() {
c := Criteria{LimitPercent: 1}
gomega.Expect(c.EffectiveLimit(5)).To(gomega.Equal(1))
})
It("returns 0 when totalCount is 0", func() {
c := Criteria{LimitPercent: 10}
gomega.Expect(c.EffectiveLimit(0)).To(gomega.Equal(0))
})
It("returns 0 when no limit is set", func() {
c := Criteria{}
gomega.Expect(c.EffectiveLimit(1000)).To(gomega.Equal(0))
})
It("returns full count for 100%", func() {
c := Criteria{LimitPercent: 100}
gomega.Expect(c.EffectiveLimit(450)).To(gomega.Equal(450))
})
It("returns 1 for 1% of 50 items", func() {
c := Criteria{LimitPercent: 1}
gomega.Expect(c.EffectiveLimit(50)).To(gomega.Equal(1))
})
})
Describe("ResolveLimit", func() {
It("resolves percentage to absolute limit preserving LimitPercent", func() {
c := Criteria{LimitPercent: 10}
c.ResolveLimit(450)
gomega.Expect(c.Limit).To(gomega.Equal(45))
})
It("does nothing when Limit is already set", func() {
c := Criteria{Limit: 50, LimitPercent: 10}
c.ResolveLimit(1000)
gomega.Expect(c.Limit).To(gomega.Equal(50))
})
It("does nothing when no limit is configured", func() {
c := Criteria{}
c.ResolveLimit(1000)
gomega.Expect(c.Limit).To(gomega.Equal(0))
})
It("sets minimum 1 when percentage rounds to 0 and totalCount > 0", func() {
c := Criteria{LimitPercent: 1}
c.ResolveLimit(5)
gomega.Expect(c.Limit).To(gomega.Equal(1))
})
It("is idempotent when called twice", func() {
c := Criteria{LimitPercent: 10}
c.ResolveLimit(450)
c.ResolveLimit(450)
gomega.Expect(c.Limit).To(gomega.Equal(45))
})
})
Describe("IsPercentageLimit", func() {
It("returns true when LimitPercent is set and Limit is 0", func() {
c := Criteria{LimitPercent: 10}
gomega.Expect(c.IsPercentageLimit()).To(gomega.BeTrue())
})
It("returns false when Limit is set", func() {
c := Criteria{Limit: 50, LimitPercent: 10}
gomega.Expect(c.IsPercentageLimit()).To(gomega.BeFalse())
})
It("returns false when neither is set", func() {
c := Criteria{}
gomega.Expect(c.IsPercentageLimit()).To(gomega.BeFalse())
})
It("returns false when LimitPercent is out of range", func() {
c := Criteria{LimitPercent: 150}
gomega.Expect(c.IsPercentageLimit()).To(gomega.BeFalse())
})
})
})
Describe("refreshDelay", func() {
newCriteria := func(extra string) []byte {
return []byte(`{"all":[{"is":{"loved":true}}]` + extra + `}`)
}
It("unmarshals a valid refreshDelay", func() {
var c Criteria
gomega.Expect(json.Unmarshal(newCriteria(`,"refreshDelay":"1d"`), &c)).To(gomega.Succeed())
gomega.Expect(c.RefreshDelay).To(gomega.Equal(24 * time.Hour))
})
It("supports week units", func() {
var c Criteria
gomega.Expect(json.Unmarshal(newCriteria(`,"refreshDelay":"1w"`), &c)).To(gomega.Succeed())
gomega.Expect(c.RefreshDelay).To(gomega.Equal(7 * 24 * time.Hour))
})
It("leaves RefreshDelay zero when absent", func() {
var c Criteria
gomega.Expect(json.Unmarshal(newCriteria(``), &c)).To(gomega.Succeed())
gomega.Expect(c.RefreshDelay).To(gomega.BeZero())
})
It("rejects an invalid refreshDelay", func() {
var c Criteria
err := json.Unmarshal(newCriteria(`,"refreshDelay":"tomorrow"`), &c)
gomega.Expect(err).To(gomega.MatchError(gomega.ContainSubstring("refreshDelay")))
})
It("rejects a negative refreshDelay", func() {
var c Criteria
err := json.Unmarshal(newCriteria(`,"refreshDelay":"-1h"`), &c)
gomega.Expect(err).To(gomega.MatchError(gomega.ContainSubstring("refreshDelay")))
})
It("marshals RefreshDelay back as a duration string", func() {
c := Criteria{
Expression: All{Is{"loved": true}},
RefreshDelay: 24 * time.Hour,
}
j, err := json.Marshal(c)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(string(j)).To(gomega.ContainSubstring(`"refreshDelay":"1d"`))
})
It("omits refreshDelay from JSON when zero", func() {
c := Criteria{Expression: All{Is{"loved": true}}}
j, err := json.Marshal(c)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(string(j)).ToNot(gomega.ContainSubstring("refreshDelay"))
})
It("round-trips through marshal and unmarshal", func() {
c := Criteria{
Expression: All{Is{"loved": true}},
RefreshDelay: 36 * time.Hour,
}
j, err := json.Marshal(c)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
var c2 Criteria
gomega.Expect(json.Unmarshal(j, &c2)).To(gomega.Succeed())
gomega.Expect(c2.RefreshDelay).To(gomega.Equal(36 * time.Hour))
})
})
Context("with child playlists", func() {
var (
topLevelInPlaylistID string
topLevelInPlaylistPath string
topLevelNotInPlaylistID string
nestedAnyInPlaylistID string
nestedAnyNotInPlaylistID string
nestedAllInPlaylistID string
nestedAllNotInPlaylistID string
nestedAnyNotInPlaylistPath string
)
BeforeEach(func() {
topLevelInPlaylistID = uuid.NewString()
topLevelInPlaylistPath = "./test.nsp"
topLevelNotInPlaylistID = uuid.NewString()
nestedAnyInPlaylistID = uuid.NewString()
nestedAnyNotInPlaylistID = uuid.NewString()
nestedAnyNotInPlaylistPath = "../not-in-playlist.m3u"
nestedAllInPlaylistID = uuid.NewString()
nestedAllNotInPlaylistID = uuid.NewString()
goObj = Criteria{
Expression: All{
InPlaylist{"id": topLevelInPlaylistID},
InPlaylist{"path": topLevelInPlaylistPath},
NotInPlaylist{"id": topLevelNotInPlaylistID},
Any{
InPlaylist{"id": nestedAnyInPlaylistID},
NotInPlaylist{"id": nestedAnyNotInPlaylistID},
NotInPlaylist{"path": nestedAnyNotInPlaylistPath},
},
All{
InPlaylist{"id": nestedAllInPlaylistID},
NotInPlaylist{"id": nestedAllNotInPlaylistID},
},
},
}
})
It("extracts all child smart playlist IDs from expression criteria", func() {
ids := goObj.ChildPlaylistIds()
gomega.Expect(ids).To(gomega.ConsistOf(topLevelInPlaylistID, topLevelNotInPlaylistID, nestedAnyInPlaylistID, nestedAnyNotInPlaylistID, nestedAllInPlaylistID, nestedAllNotInPlaylistID))
})
It("extracts all child smart playlist paths from expression criteria", func() {
paths := goObj.ChildPlaylistPaths()
gomega.Expect(paths).To(gomega.ConsistOf(topLevelInPlaylistPath, nestedAnyNotInPlaylistPath))
})
It("ignores empty child playlist paths", func() {
c := Criteria{Expression: All{InPlaylist{"path": ""}, NotInPlaylist{"path": ""}}}
gomega.Expect(c.ChildPlaylistPaths()).To(gomega.BeEmpty())
})
It("ignores empty child playlist ids", func() {
c := Criteria{Expression: All{InPlaylist{"id": ""}, NotInPlaylist{"id": ""}}}
gomega.Expect(c.ChildPlaylistIds()).To(gomega.BeEmpty())
})
It("extracts child smart playlist IDs from deeply nested expression", func() {
goObj = Criteria{
Expression: Any{
Any{
All{
Any{
InPlaylist{"id": nestedAnyInPlaylistID},
NotInPlaylist{"id": nestedAnyNotInPlaylistID},
Any{
All{
InPlaylist{"id": nestedAllInPlaylistID},
NotInPlaylist{"id": nestedAllNotInPlaylistID},
},
},
},
},
},
},
}
ids := goObj.ChildPlaylistIds()
gomega.Expect(ids).To(gomega.ConsistOf(nestedAnyInPlaylistID, nestedAnyNotInPlaylistID, nestedAllInPlaylistID, nestedAllNotInPlaylistID))
})
It("returns empty list when no child playlist IDs are present", func() {
ids := Criteria{}.ChildPlaylistIds()
gomega.Expect(ids).To(gomega.BeEmpty())
})
It("returns empty list for leaf expressions", func() {
ids := Criteria{Expression: Is{"title": "Low Rider"}}.ChildPlaylistIds()
gomega.Expect(ids).To(gomega.BeEmpty())
})
It("deduplicates repeated playlist IDs", func() {
sharedID := uuid.NewString()
goObj = Criteria{
Expression: All{
InPlaylist{"id": sharedID},
Any{
InPlaylist{"id": sharedID},
NotInPlaylist{"id": sharedID},
},
},
}
ids := goObj.ChildPlaylistIds()
gomega.Expect(ids).To(gomega.Equal([]string{sharedID}))
})
})
})
+187
View File
@@ -0,0 +1,187 @@
package criteria
import (
"encoding/json"
"fmt"
"strconv"
"strings"
)
type unmarshalConjunctionType []Expression
func (uc *unmarshalConjunctionType) UnmarshalJSON(data []byte) error {
var raw []map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
var es unmarshalConjunctionType
for _, e := range raw {
for k, v := range e {
k = strings.ToLower(k)
expr := unmarshalExpression(k, v)
if expr == nil {
expr = unmarshalConjunction(k, v)
}
if expr == nil {
return fmt.Errorf(`invalid expression key '%s'`, k)
}
es = append(es, expr)
}
}
*uc = es
return nil
}
// optionalConjunction is a top-level "all"/"any" value that remembers whether its
// key was present at all, so a Criteria providing both can be rejected. encoding/json
// calls UnmarshalJSON even for a JSON null, so present is set whenever the key appears
// — including as [] or null — while an absent key leaves it false.
type optionalConjunction struct {
present bool
rules unmarshalConjunctionType
}
func (o *optionalConjunction) UnmarshalJSON(data []byte) error {
o.present = true
return json.Unmarshal(data, &o.rules)
}
func unmarshalExpression(opName string, rawValue json.RawMessage) Expression {
m := make(map[string]any)
err := json.Unmarshal(rawValue, &m)
if err != nil {
return nil
}
normalizeBoolFields(m)
switch opName {
case "is":
return Is(m)
case "isnot":
return IsNot(m)
case "gt":
return Gt(m)
case "lt":
return Lt(m)
case "contains":
return Contains(m)
case "notcontains":
return NotContains(m)
case "startswith":
return StartsWith(m)
case "endswith":
return EndsWith(m)
case "intherange":
return InTheRange(m)
case "before":
return Before(m)
case "after":
return After(m)
case "inthelast":
return InTheLast(m)
case "notinthelast":
return NotInTheLast(m)
case "inplaylist":
return InPlaylist(m)
case "notinplaylist":
return NotInPlaylist(m)
case "ismissing":
normalizeAllBoolFields(m)
return IsMissing(m)
case "ispresent":
normalizeAllBoolFields(m)
return IsPresent(m)
}
return nil
}
func normalizeAllBoolFields(m map[string]any) {
for k, v := range m {
m[k] = normalizeBoolValue(v)
}
}
func normalizeBoolFields(m map[string]any) {
for field, value := range m {
info, ok := LookupField(field)
if ok && info.Boolean {
m[field] = normalizeBoolValue(value)
}
}
}
// ToBool coerces a criteria value to a bool, accepting the forms criteria values take: a real bool,
// a strconv.ParseBool-parseable string, or a JSON number that is exactly 0 or 1. Any other value
// (other numbers, slices, nil, unparseable strings) returns ok=false so callers can handle it.
func ToBool(v any) (bool, bool) {
switch val := v.(type) {
case bool:
return val, true
case string:
b, err := strconv.ParseBool(val)
return b, err == nil
case float64:
switch val {
case 1:
return true, true
case 0:
return false, true
}
}
return false, false
}
// normalizeBoolValue leaves non-boolean values unchanged so they flow through to their own validation.
func normalizeBoolValue(v any) any {
if b, ok := ToBool(v); ok {
return b
}
return v
}
func unmarshalConjunction(conjName string, rawValue json.RawMessage) Expression {
var items unmarshalConjunctionType
err := json.Unmarshal(rawValue, &items)
if err != nil {
return nil
}
switch conjName {
case "any":
return Any(items)
case "all":
return All(items)
}
return nil
}
func marshalExpression(name string, value map[string]any) ([]byte, error) {
if len(value) != 1 {
return nil, fmt.Errorf(`invalid %s expression length %d for values %v`, name, len(value), value)
}
b := strings.Builder{}
b.WriteString(`{"` + name + `":{`)
for f, v := range value {
j, err := json.Marshal(v)
if err != nil {
return nil, err
}
b.WriteString(`"` + f + `":`)
b.Write(j)
break
}
b.WriteString("}}")
return []byte(b.String()), nil
}
func marshalConjunction(name string, conj []Expression) ([]byte, error) {
aux := struct {
All []Expression `json:"all,omitempty"`
Any []Expression `json:"any,omitempty"`
}{}
if name == "any" {
aux.Any = conj
} else {
aux.All = conj
}
return json.Marshal(aux)
}