mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-07 02:38:50 +01:00
feat(smartplaylists): add support for referencing playlists using paths (#5187)
* 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>
This commit is contained in:
parent
672c0af580
commit
49f626c00a
9 changed files with 370 additions and 51 deletions
|
|
@ -64,18 +64,21 @@ func (c Criteria) IsPercentageLimit() bool {
|
|||
}
|
||||
|
||||
func (c Criteria) ChildPlaylistIds() []string {
|
||||
if c.Expression == nil {
|
||||
return nil
|
||||
}
|
||||
return c.childPlaylistRefs(conjunction.ChildPlaylistIds)
|
||||
}
|
||||
|
||||
func (c Criteria) ChildPlaylistPaths() []string {
|
||||
return c.childPlaylistRefs(conjunction.ChildPlaylistPaths)
|
||||
}
|
||||
|
||||
func (c Criteria) childPlaylistRefs(extract func(conjunction) []string) []string {
|
||||
parent, ok := c.Expression.(conjunction)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
ids := parent.ChildPlaylistIds()
|
||||
slices.Sort(ids)
|
||||
return slices.Compact(ids)
|
||||
refs := extract(parent)
|
||||
slices.Sort(refs)
|
||||
return slices.Compact(refs)
|
||||
}
|
||||
|
||||
func (c Criteria) MarshalJSON() ([]byte, error) {
|
||||
|
|
|
|||
|
|
@ -323,19 +323,23 @@ var _ = Describe("Criteria", func() {
|
|||
|
||||
Context("with child playlists", func() {
|
||||
var (
|
||||
topLevelInPlaylistID string
|
||||
topLevelNotInPlaylistID string
|
||||
nestedAnyInPlaylistID string
|
||||
nestedAnyNotInPlaylistID string
|
||||
nestedAllInPlaylistID string
|
||||
nestedAllNotInPlaylistID string
|
||||
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()
|
||||
|
|
@ -343,10 +347,12 @@ var _ = Describe("Criteria", func() {
|
|||
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},
|
||||
|
|
@ -359,6 +365,18 @@ var _ = Describe("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{
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
package criteria
|
||||
|
||||
// Conjunctions need to implement this interface, to allow Criteria to extract child playlist IDs recursively
|
||||
// Conjunctions need to implement this interface, to allow Criteria to extract child playlist references recursively
|
||||
type conjunction interface {
|
||||
ChildPlaylistIds() []string
|
||||
ChildPlaylistPaths() []string
|
||||
}
|
||||
|
||||
type (
|
||||
|
|
@ -16,9 +17,9 @@ func (all All) MarshalJSON() ([]byte, error) {
|
|||
return marshalConjunction("all", all)
|
||||
}
|
||||
|
||||
func (all All) ChildPlaylistIds() (ids []string) {
|
||||
return extractPlaylistIds(all)
|
||||
}
|
||||
func (all All) ChildPlaylistIds() []string { return extractPlaylistField(all, "id") }
|
||||
|
||||
func (all All) ChildPlaylistPaths() []string { return extractPlaylistField(all, "path") }
|
||||
|
||||
type (
|
||||
Any []Expression
|
||||
|
|
@ -31,9 +32,9 @@ func (any Any) MarshalJSON() ([]byte, error) {
|
|||
return marshalConjunction("any", any)
|
||||
}
|
||||
|
||||
func (any Any) ChildPlaylistIds() (ids []string) {
|
||||
return extractPlaylistIds(any)
|
||||
}
|
||||
func (any Any) ChildPlaylistIds() []string { return extractPlaylistField(any, "id") }
|
||||
|
||||
func (any Any) ChildPlaylistPaths() []string { return extractPlaylistField(any, "path") }
|
||||
|
||||
type Is map[string]any
|
||||
type Eq = Is
|
||||
|
|
@ -172,28 +173,20 @@ func (ip IsPresent) MarshalJSON() ([]byte, error) {
|
|||
|
||||
func (ip IsPresent) fields() map[string]any { return ip }
|
||||
|
||||
func extractPlaylistIds(inputRule any) (ids []string) {
|
||||
var id string
|
||||
var ok bool
|
||||
|
||||
func extractPlaylistField(inputRule any, field string) (values []string) {
|
||||
switch rule := inputRule.(type) {
|
||||
case Any:
|
||||
for _, rules := range rule {
|
||||
ids = append(ids, extractPlaylistIds(rules)...)
|
||||
values = append(values, extractPlaylistField(rules, field)...)
|
||||
}
|
||||
case All:
|
||||
for _, rules := range rule {
|
||||
ids = append(ids, extractPlaylistIds(rules)...)
|
||||
values = append(values, extractPlaylistField(rules, field)...)
|
||||
}
|
||||
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)
|
||||
case InPlaylist, NotInPlaylist:
|
||||
if value, ok := rule.(Expression).fields()[field].(string); ok && value != "" {
|
||||
values = append(values, value)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,16 @@ package model
|
|||
|
||||
import (
|
||||
"iter"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
)
|
||||
|
||||
|
|
@ -137,6 +141,66 @@ func (pls Playlist) UploadedImagePath() string {
|
|||
return UploadedImagePath(consts.EntityPlaylist, pls.UploadedImage)
|
||||
}
|
||||
|
||||
// NormalizedRules returns the rules with child playlist paths resolved to absolute, OS-native paths.
|
||||
func (pls Playlist) NormalizedRules() *criteria.Criteria {
|
||||
if pls.Rules == nil || pls.Rules.Expression == nil {
|
||||
return pls.Rules
|
||||
}
|
||||
|
||||
rules := *pls.Rules
|
||||
rules.Expression = normalizePlaylistPaths(pls.Rules.Expression, pls.Path)
|
||||
return &rules
|
||||
}
|
||||
|
||||
func normalizePlaylistPaths(inputRule criteria.Expression, referencingPlaylistPath string) criteria.Expression {
|
||||
switch rule := inputRule.(type) {
|
||||
case criteria.Any:
|
||||
anyCriteria := make(criteria.Any, len(rule))
|
||||
for i, rules := range rule {
|
||||
anyCriteria[i] = normalizePlaylistPaths(rules, referencingPlaylistPath)
|
||||
}
|
||||
return anyCriteria
|
||||
case criteria.All:
|
||||
allCriteria := make(criteria.All, len(rule))
|
||||
for i, rules := range rule {
|
||||
allCriteria[i] = normalizePlaylistPaths(rules, referencingPlaylistPath)
|
||||
}
|
||||
return allCriteria
|
||||
case criteria.InPlaylist:
|
||||
return criteria.InPlaylist(normalizeChildPathRule(rule, referencingPlaylistPath))
|
||||
case criteria.NotInPlaylist:
|
||||
return criteria.NotInPlaylist(normalizeChildPathRule(rule, referencingPlaylistPath))
|
||||
}
|
||||
|
||||
return inputRule
|
||||
}
|
||||
|
||||
func normalizeChildPathRule(rule map[string]any, referencingPlaylistPath string) map[string]any {
|
||||
path, ok := rule["path"].(string)
|
||||
if !ok || path == "" {
|
||||
return rule
|
||||
}
|
||||
|
||||
// References use forward slashes to stay portable, while Playlist.Path is OS-native.
|
||||
path = filepath.FromSlash(path)
|
||||
switch {
|
||||
case isAbsPlaylistRef(path):
|
||||
path = filepath.Clean(path)
|
||||
case referencingPlaylistPath != "":
|
||||
path = filepath.Join(filepath.Dir(referencingPlaylistPath), path)
|
||||
default:
|
||||
log.Warn("Cannot resolve relative playlist reference: playlist has no file path", "reference", path)
|
||||
}
|
||||
normalized := maps.Clone(rule)
|
||||
normalized["path"] = path
|
||||
return normalized
|
||||
}
|
||||
|
||||
// filepath.IsAbs rejects a bare leading separator on Windows, but that is how Unix spells absolute.
|
||||
func isAbsPlaylistRef(path string) bool {
|
||||
return filepath.IsAbs(path) || os.IsPathSeparator(path[0])
|
||||
}
|
||||
|
||||
type Playlists []Playlist
|
||||
|
||||
type PlaylistCursor iter.Seq2[Playlist, error]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package model_test
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
|
|
@ -88,4 +89,102 @@ var _ = Describe("Playlist", func() {
|
|||
Expect(model.Playlist{Sync: true}.TracksEditable()).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("NormalizedRules()", func() {
|
||||
// absPath builds an OS-native absolute path so these specs also run on Windows.
|
||||
absPath := func(parts ...string) string {
|
||||
abs, err := filepath.Abs(filepath.Join(parts...))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
return abs
|
||||
}
|
||||
normalize := func(pls model.Playlist) criteria.Expression {
|
||||
return pls.NormalizedRules().Expression
|
||||
}
|
||||
|
||||
It("resolves relative references against the playlist folder", func() {
|
||||
pls := model.Playlist{
|
||||
Path: absPath("test", "nested", "my-playlist.nsp"),
|
||||
Rules: &criteria.Criteria{Expression: criteria.All{
|
||||
criteria.InPlaylist{"path": "../up.m3u"},
|
||||
criteria.NotInPlaylist{"path": "./sibling.nsp"},
|
||||
criteria.Any{criteria.InPlaylist{"path": "sub/deep.nsp"}},
|
||||
}},
|
||||
}
|
||||
Expect(normalize(pls)).To(BeEquivalentTo(criteria.All{
|
||||
criteria.InPlaylist{"path": absPath("test", "up.m3u")},
|
||||
criteria.NotInPlaylist{"path": absPath("test", "nested", "sibling.nsp")},
|
||||
criteria.Any{criteria.InPlaylist{"path": absPath("test", "nested", "sub", "deep.nsp")}},
|
||||
}))
|
||||
})
|
||||
|
||||
It("cleans absolute references", func() {
|
||||
dirty := absPath("music") + string(filepath.Separator) + "." + string(filepath.Separator) + "child.nsp"
|
||||
pls := model.Playlist{
|
||||
Path: absPath("test", "my-playlist.nsp"),
|
||||
Rules: &criteria.Criteria{Expression: criteria.All{criteria.NotInPlaylist{"path": dirty}}},
|
||||
}
|
||||
Expect(normalize(pls)).To(BeEquivalentTo(criteria.All{
|
||||
criteria.NotInPlaylist{"path": absPath("music", "child.nsp")},
|
||||
}))
|
||||
})
|
||||
|
||||
It("treats a leading slash as absolute on every OS", func() {
|
||||
pls := model.Playlist{
|
||||
Path: absPath("test", "my-playlist.nsp"),
|
||||
Rules: &criteria.Criteria{Expression: criteria.All{criteria.InPlaylist{"path": "/other/./root.m3u"}}},
|
||||
}
|
||||
Expect(normalize(pls)).To(BeEquivalentTo(criteria.All{
|
||||
criteria.InPlaylist{"path": filepath.FromSlash("/other/root.m3u")},
|
||||
}))
|
||||
})
|
||||
|
||||
It("leaves empty paths and id references untouched", func() {
|
||||
pls := model.Playlist{
|
||||
Path: absPath("test", "my-playlist.nsp"),
|
||||
Rules: &criteria.Criteria{Expression: criteria.All{
|
||||
criteria.InPlaylist{"path": ""},
|
||||
criteria.InPlaylist{"id": "94d8ba52-7aca-40e2-af82-4cb09c43d710"},
|
||||
criteria.Eq{"artist": "Bob Dealin"},
|
||||
}},
|
||||
}
|
||||
Expect(normalize(pls)).To(BeEquivalentTo(criteria.All{
|
||||
criteria.InPlaylist{"path": ""},
|
||||
criteria.InPlaylist{"id": "94d8ba52-7aca-40e2-af82-4cb09c43d710"},
|
||||
criteria.Eq{"artist": "Bob Dealin"},
|
||||
}))
|
||||
})
|
||||
|
||||
It("skips relative references when the playlist has no path", func() {
|
||||
pls := model.Playlist{
|
||||
Rules: &criteria.Criteria{Expression: criteria.All{criteria.InPlaylist{"path": "../up.m3u"}}},
|
||||
}
|
||||
Expect(normalize(pls)).To(BeEquivalentTo(criteria.All{
|
||||
criteria.InPlaylist{"path": filepath.FromSlash("../up.m3u")},
|
||||
}))
|
||||
})
|
||||
|
||||
It("preserves every other criteria field", func() {
|
||||
rules := criteria.Criteria{
|
||||
Expression: criteria.All{criteria.InPlaylist{"path": "child.nsp"}},
|
||||
Sort: "title",
|
||||
Order: "desc",
|
||||
Limit: 10,
|
||||
LimitPercent: 25,
|
||||
Offset: 5,
|
||||
RefreshDelay: 3 * time.Hour,
|
||||
}
|
||||
pls := model.Playlist{Path: absPath("test", "my-playlist.nsp"), Rules: &rules}
|
||||
|
||||
normalized := *pls.NormalizedRules()
|
||||
normalized.Expression = rules.Expression
|
||||
Expect(normalized).To(Equal(rules))
|
||||
})
|
||||
|
||||
It("does not mutate the original playlist rules", func() {
|
||||
original := criteria.All{criteria.InPlaylist{"path": "child.nsp"}}
|
||||
pls := model.Playlist{Path: absPath("test", "my-playlist.nsp"), Rules: &criteria.Criteria{Expression: original}}
|
||||
_ = pls.NormalizedRules()
|
||||
Expect(original[0]).To(BeEquivalentTo(criteria.InPlaylist{"path": "child.nsp"}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue