From 622b1d02b91497160c91b937cc12648f69063169 Mon Sep 17 00:00:00 2001 From: David Date: Mon, 16 Mar 2026 21:41:47 -0500 Subject: [PATCH] feat: Support relative playlist paths in smartlists Signed-off-by: David --- model/playlist.go | 37 +++++++++++ model/playlist_test.go | 80 ++++++++++++++++++++++++ persistence/smart_playlist_repository.go | 8 ++- 3 files changed, 124 insertions(+), 1 deletion(-) diff --git a/model/playlist.go b/model/playlist.go index dc549f039..d9617cd47 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -1,6 +1,7 @@ package model import ( + "path/filepath" "slices" "strconv" "time" @@ -117,6 +118,42 @@ func (pls Playlist) UploadedImagePath() string { return UploadedImagePath(consts.EntityPlaylist, pls.UploadedImage) } +func (pls Playlist) NormalizeChildPaths() { + if pls.Rules.Expression == nil { + return + } + + normalizePlaylistPaths(pls.Rules.Expression, pls.Path) +} + +func normalizePlaylistPaths(inputRule any, referencingPlaylistPath string) { + switch rule := inputRule.(type) { + case criteria.Any: + for _, rules := range rule { + normalizePlaylistPaths(rules, referencingPlaylistPath) + } + case criteria.All: + for _, rules := range rule { + normalizePlaylistPaths(rules, referencingPlaylistPath) + } + case criteria.InPlaylist: + dir := filepath.Dir(referencingPlaylistPath) + if path, ok := rule["path"].(string); ok { + if !filepath.IsAbs(path) { + rule["path"] = filepath.Clean(filepath.Join(dir, path)) + } + } + case criteria.NotInPlaylist: + dir := filepath.Dir(referencingPlaylistPath) + if path, ok := rule["path"].(string); ok { + if !filepath.IsAbs(path) { + rule["path"] = filepath.Clean(filepath.Join(dir, path)) + } + } + } + return +} + type Playlists []Playlist type PlaylistRepository interface { diff --git a/model/playlist_test.go b/model/playlist_test.go index 9ed24f00f..2f8a9222e 100644 --- a/model/playlist_test.go +++ b/model/playlist_test.go @@ -2,6 +2,7 @@ package model_test import ( "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -43,4 +44,83 @@ var _ = Describe("Playlist", func() { Expect(pls.ToM3U8()).To(Equal(expected)) }) }) + + Describe("NormalizeChildPaths()", func() { + It("normalizes file paths", func() { + pls := model.Playlist{Rules: &criteria.Criteria{ + Expression: criteria.All{ + criteria.InPlaylist{"path": "/test/my-test-path.m3u"}, + criteria.InPlaylist{"path": "../my-test-path.m3u"}, + criteria.NotInPlaylist{"path": "/not-test/not-my-test-path.m3u"}, + criteria.Any{ + criteria.InPlaylist{"path": "../../in-the-test.nsp"}, + criteria.NotInPlaylist{"path": "./sibling.nsp"}, + criteria.All{ + criteria.InPlaylist{"path": "/other-root/other.m3u"}, + criteria.NotInPlaylist{"path": "../../../out-of-containment.nsp"}, + }, + }, + }, + }, + Path: "/test/nested/my-playlist.nsp"} + + pls.NormalizeChildPaths() + Expect(pls.Rules).Should(BeEquivalentTo(&criteria.Criteria{ + Expression: criteria.All{ + criteria.InPlaylist{"path": "/test/my-test-path.m3u"}, + criteria.InPlaylist{"path": "/test/my-test-path.m3u"}, + criteria.NotInPlaylist{"path": "/not-test/not-my-test-path.m3u"}, + criteria.Any{ + criteria.InPlaylist{"path": "/in-the-test.nsp"}, + criteria.NotInPlaylist{"path": "/test/nested/sibling.nsp"}, + criteria.All{ + criteria.InPlaylist{"path": "/other-root/other.m3u"}, + criteria.NotInPlaylist{"path": "/out-of-containment.nsp"}, + }, + }, + }, + })) + }) + + It("normalizes various file paths", func() { + // Absolute path + pls := model.Playlist{ID: "123"} + pls.Rules = &criteria.Criteria{ + Expression: criteria.All{ + criteria.InPlaylist{"path": "/test/my-test-path.m3u"}, + }, + } + + pls.NormalizeChildPaths() + Expect(pls.Rules).NotTo(BeNil()) + }) + + It("handles relative paths correctly", func() { + pls := model.Playlist{ID: "123", Path: "/test/my-playlist.m3u"} + pls.Rules = &criteria.Criteria{ + Expression: criteria.All{ + criteria.InPlaylist{"path": "../my-test-path.m3u"}, + }, + } + + pls.NormalizeChildPaths() + Expect(pls.Rules).Should(BeEquivalentTo(&criteria.Criteria{ + Expression: criteria.All{ + criteria.InPlaylist{"path": "/my-test-path.m3u"}, + }, + })) + }) + + It("ignores non-path entries", func() { + pls := model.Playlist{ID: "123"} + pls.Rules = &criteria.Criteria{ + Expression: criteria.All{ + criteria.InPlaylist{"path": "/not-test/not-my-test-path.m3u"}, + }, + } + + pls.NormalizeChildPaths() + Expect(pls.Rules).NotTo(BeNil()) + }) + }) }) diff --git a/persistence/smart_playlist_repository.go b/persistence/smart_playlist_repository.go index 926bfdc7f..d67b7c912 100644 --- a/persistence/smart_playlist_repository.go +++ b/persistence/smart_playlist_repository.go @@ -91,8 +91,14 @@ func (r *playlistRepository) shouldRefreshSmartPlaylist(pls *model.Playlist, usr // Returns false if child playlists could not be loaded (DB error), signaling the parent refresh should abort. func (r *playlistRepository) refreshChildPlaylists(pls *model.Playlist, rulesSQL smartPlaylistCriteria) bool { childPlaylistIds := rulesSQL.ChildPlaylistIds() + + if len(childPlaylistIds) == 0 { + return true + } + + pls.NormalizeChildPaths() childPlaylistPaths := rulesSQL.ChildPlaylistPaths() - if len(childPlaylistIds) == 0 || len(childPlaylistPaths) == 0 { + if len(childPlaylistPaths) == 0 { return true }