mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-18 07:30:29 +00:00
* feat: Add support for referencing playlists using paths Signed-off-by: David <dvedvick@gmail.com> * feat: Support relative playlist paths in smartlists Signed-off-by: David <dvedvick@gmail.com> * fix(smartplaylists): protect against nil panic Signed-off-by: David <dvedvick@gmail.com> * fix(smartplaylists): refreshing child playlists Signed-off-by: David <dvedvick@gmail.com> * chore(smartplaylists): log field parsing error Signed-off-by: David <dvedvick@gmail.com> * fix(smartplaylists): handle empty playlist paths Signed-off-by: David <dvedvick@gmail.com> * refactor(smartplaylists): make NormalizeChildPaths non-mutating Signed-off-by: David <dvedvick@gmail.com> * fix(smartplaylists): stop warning on every inPlaylist rule without the looked-up field Rules that reference a playlist by id have no path field, and the reverse, so the warning fired on every refresh. The log call also had a bad argument count. * fix(smartplaylists): ignore empty inPlaylist id and path references An empty path matched every playlist without a file path, including the referencing playlist itself, so the refresh recursed until the stack overflowed. An empty id also shadowed a valid path in the same rule. * fix(smartplaylists): match inPlaylist paths in both NFC and NFD forms A playlist path is stored in the Unicode form the filesystem reports, which can differ from the form typed in the .nsp file. The exact comparison then found no playlist for names with accents. * fix(smartplaylists): keep all criteria fields when normalizing child paths The field-by-field copy dropped RefreshDelay. * fix(smartplaylists): clean absolute inPlaylist path references Only relative references were cleaned, so an absolute reference such as /music/./child.nsp never matched the stored /music/child.nsp. * fix(smartplaylists): stop infinite recursion on playlists that reference each other Two smart playlists referencing each other, by id or by path, recursed until the stack overflowed and the server died. The refresh now tracks visited playlists. * fix(smartplaylists): resolve inPlaylist path references with OS-native separators Playlist.Path is OS-native, but references in a .nsp file use forward slashes. On Windows they never matched, and a leading slash was not seen as absolute. The specs now build OS-native paths, so they also run on Windows. * fix(smartplaylists): warn when a relative inPlaylist path cannot be resolved A playlist created in the UI has no file path, so a relative reference silently matched nothing. * refactor(smartplaylists): simplify child playlist reference handling Share one extractor for child ids and paths, return only the normalized rules instead of a playlist copy, and resolve each path reference in a single switch. * test(smartplaylists): store the Unicode child path in OS-native form Playlist.Path is OS-native, so on Windows the forward-slash fixture never matched the normalized reference. --------- Signed-off-by: David <dvedvick@gmail.com> Co-authored-by: Deluan Quintão <deluan@navidrome.org>
427 lines
13 KiB
Go
427 lines
13 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
|
|
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}))
|
|
})
|
|
})
|
|
})
|