mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
* feat(utils): add ParseDuration/FormatDuration with day and week units * feat(criteria): add per-playlist refreshDelay to smart playlist rules * feat(smartplaylist): honor per-playlist refreshDelay in refresh gate * feat(subsonic): compute smart playlist validUntil from effective refresh delay * fix(playlists): reset smart playlist evaluation window when rules change via API * refactor(utils): flatten FormatDuration recursion, single-pass duration regex * fix(playlists): address PR review feedback - Reset EvaluatedAt to nil instead of zero-time on rules change and NSP re-import, so getPlaylist(s) never reports year-1 Changed/validUntil in the window between an edit and the next owner read - Parse negative d/w durations so they are rejected with the consistent "negative duration" error instead of "unknown unit" - Quote input in the negative-duration error, matching the parse error - Gate per-playlist RefreshDelay behind IsSmartPlaylist, matching its doc
409 lines
12 KiB
Go
409 lines
12 KiB
Go
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
|
|
topLevelNotInPlaylistID string
|
|
nestedAnyInPlaylistID string
|
|
nestedAnyNotInPlaylistID string
|
|
nestedAllInPlaylistID string
|
|
nestedAllNotInPlaylistID string
|
|
)
|
|
BeforeEach(func() {
|
|
topLevelInPlaylistID = uuid.NewString()
|
|
topLevelNotInPlaylistID = uuid.NewString()
|
|
|
|
nestedAnyInPlaylistID = uuid.NewString()
|
|
nestedAnyNotInPlaylistID = uuid.NewString()
|
|
|
|
nestedAllInPlaylistID = uuid.NewString()
|
|
nestedAllNotInPlaylistID = uuid.NewString()
|
|
|
|
goObj = Criteria{
|
|
Expression: All{
|
|
InPlaylist{"id": topLevelInPlaylistID},
|
|
NotInPlaylist{"id": topLevelNotInPlaylistID},
|
|
Any{
|
|
InPlaylist{"id": nestedAnyInPlaylistID},
|
|
NotInPlaylist{"id": nestedAnyNotInPlaylistID},
|
|
},
|
|
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 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}))
|
|
})
|
|
})
|
|
})
|