diff --git a/conf/configuration.go b/conf/configuration.go index efa9cbf9a..cd5ea8d3a 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -816,6 +816,50 @@ func disableExternalServices() { } } +// PlaylistsPathPatterns returns the PlaylistsPath globs in the slash-separated form doublestar matches. +func PlaylistsPathPatterns() []string { + var patterns []string + for pattern := range strings.SplitSeq(Server.PlaylistsPath, string(filepath.ListSeparator)) { + patterns = append(patterns, toGlobPattern(pattern, filepath.Separator == '\\')) + } + return patterns +} + +// toGlobPattern turns Windows '\' separators into '/'. Windows names can contain brackets and +// braces, so an escaped pair ('\[...\]', '\{...\}') and a lone '\]' or '\}' stay escapes. +func toGlobPattern(pattern string, windows bool) string { + if !windows { + return pattern + } + var sb strings.Builder + for i := 0; i < len(pattern); i++ { + if pattern[i] != '\\' { + sb.WriteByte(pattern[i]) + continue + } + if i+1 < len(pattern) && isEscape(pattern[i+1], pattern[i+2:]) { + sb.WriteString(pattern[i : i+2]) + i++ + continue + } + sb.WriteByte('/') + } + return sb.String() +} + +// isEscape reports whether a '\' followed by c (and then rest) escapes c rather than separating. +func isEscape(c byte, rest string) bool { + closer := map[byte]byte{'[': ']', '{': '}'}[c] + switch { + case c == ']' || c == '}': + return true + case closer == 0: + return false + } + next := strings.IndexByte(rest, '\\') + return next >= 0 && next+1 < len(rest) && rest[next+1] == closer +} + func validatePlaylistsPath() error { for path := range strings.SplitSeq(Server.PlaylistsPath, string(filepath.ListSeparator)) { _, err := doublestar.Match(path, "") diff --git a/conf/configuration_test.go b/conf/configuration_test.go index 8c4c8ab86..e51d3084c 100644 --- a/conf/configuration_test.go +++ b/conf/configuration_test.go @@ -379,6 +379,44 @@ var _ = Describe("Configuration", func() { ) }) + Describe("PlaylistsPathPatterns", func() { + It("splits the list with the OS list separator", func() { + conf.Server.PlaylistsPath = "." + string(filepath.ListSeparator) + "Playlists/**" + Expect(conf.PlaylistsPathPatterns()).To(Equal([]string{".", "Playlists/**"})) + }) + + DescribeTable("converts a Windows pattern to slash form", + func(pattern, expected string) { + Expect(conf.ToGlobPattern(pattern, true)).To(Equal(expected)) + }, + Entry("separator", `Playlists\navidrome`, "Playlists/navidrome"), + Entry("separator before **", `Playlists\**`, "Playlists/**"), + Entry("separator before braces", `Playlists\{rock,jazz}`, "Playlists/{rock,jazz}"), + Entry("trailing separator", `Playlists\`, "Playlists/"), + Entry("escaped brackets", `\[Mix\]`, `\[Mix\]`), + Entry("escaped brackets after a slash", `Playlists/\[Mix\]`, `Playlists/\[Mix\]`), + Entry("escaped brackets after a separator", `Playlists\\[Mix\]`, `Playlists/\[Mix\]`), + Entry("character class", `[[]Mix]`, `[[]Mix]`), + Entry("separator before a character class", `Playlists\[[]Mix]`, "Playlists/[[]Mix]"), + Entry("separator before a bracket range", `Playlists\[ab]`, "Playlists/[ab]"), + Entry("escaped braces", `\{Mix\}`, `\{Mix\}`), + Entry("escaped braces after a separator", `Playlists\\{Mix\}`, `Playlists/\{Mix\}`), + Entry("lone escaped closing bracket", `Mix\]`, `Mix\]`), + // Ambiguous: an escaped pair wins, so a separator right before it must be '/' or '\\' + Entry("escaped pair right after a name", `Playlists\[Mix\]`, `Playlists\[Mix\]`), + Entry("forward slashes", "Playlists/navidrome", "Playlists/navidrome"), + ) + + DescribeTable("keeps a non-Windows pattern as is", + func(pattern string) { + Expect(conf.ToGlobPattern(pattern, false)).To(Equal(pattern)) + }, + Entry("backslash escape", `Playlists\navidrome`), + Entry("escaped brackets", `\[Mix\]`), + Entry("escaped star", `\*`), + ) + }) + Describe("MaxImageSize floor", func() { BeforeEach(func() { viper.Reset() diff --git a/conf/export_test.go b/conf/export_test.go index d1e1a6f99..cf074b92b 100644 --- a/conf/export_test.go +++ b/conf/export_test.go @@ -36,3 +36,5 @@ func SetLogFatal(f func(...any)) func() { var UnknownConfigKeys = unknownConfigKeys var SuggestOptions = suggestOptions + +var ToGlobPattern = toGlobPattern diff --git a/core/playlists/import_test.go b/core/playlists/import_test.go index 050bbf48b..6ed7b8020 100644 --- a/core/playlists/import_test.go +++ b/core/playlists/import_test.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "runtime" "strconv" "strings" "time" @@ -1199,6 +1200,43 @@ var _ = Describe("Playlists - Import", func() { Expect(playlists.InPath(folder2)).To(BeTrue()) }) + + // Folders built like the scanner does (no LibraryPath), on the native OS + DescribeTable("matches scanner folders", + func(pattern, folderPath string, expected bool) { + conf.Server.PlaylistsPath = pattern + f := model.NewFolder(model.Library{ID: 1, Path: GinkgoT().TempDir()}, folderPath) + Expect(playlists.InPath(*f)).To(Equal(expected)) + }, + Entry("nested folder, exact pattern", "Playlists/navidrome", "Playlists/navidrome", true), + Entry("nested folder, ** pattern", "Playlists/**", "Playlists/navidrome/Deep", true), + Entry("nested folder, second item of a list", "."+string(filepath.ListSeparator)+"Playlists/navidrome", "Playlists/navidrome", true), + Entry("top-level folder", "Playlists", "Playlists", true), + Entry("root folder, '.' in a list", "."+string(filepath.ListSeparator)+"Playlists/navidrome", ".", true), + Entry("sibling folder is excluded", "Playlists/navidrome", "Playlists/other", false), + Entry("child folder is excluded by an exact pattern", "Playlists/navidrome", "Playlists/navidrome/Deep", false), + Entry("root folder is excluded by a nested pattern", "Playlists/navidrome", ".", false), + Entry("escaped brackets, top-level", `\[Mix\]`, "[Mix]", true), + Entry("escaped brackets, nested", `Playlists/\[Mix\]`, "Playlists/[Mix]", true), + Entry("escaped brackets exclude a plain folder", `\[Mix\]`, "Mix", false), + Entry("escaped brackets, nested, exclude a plain folder", `Playlists/\[Mix\]`, "Playlists/Mix", false), + Entry("character class literal, top-level", `[[]Mix]`, "[Mix]", true), + Entry("character class literal, nested", `Playlists/[[]Mix]`, "Playlists/[Mix]", true), + Entry("unescaped brackets are a character class", `[Mix]`, "[Mix]", false), + Entry("brace alternatives", "Playlists/{rock,jazz}", "Playlists/jazz", true), + Entry("brace alternatives exclude others", "Playlists/{rock,jazz}", "Playlists/pop", false), + // Backslash is a path separator on Windows (except in escaped brackets or braces), an escape elsewhere + Entry("backslash separator", `Playlists\navidrome`, "Playlists/navidrome", runtime.GOOS == "windows"), + Entry("backslash separator before **", `Playlists\**`, "Playlists/navidrome/Deep", runtime.GOOS == "windows"), + Entry("backslash separator before braces", `Playlists\{rock,jazz}`, "Playlists/rock", runtime.GOOS == "windows"), + Entry("backslash separator, sibling folder is excluded", `Playlists\navidrome`, "Playlists/other", false), + Entry("backslash before escaped brackets, nested", `Playlists\\[Mix\]`, "Playlists/[Mix]", runtime.GOOS == "windows"), + Entry("backslash separator before a character class", `Playlists\[[]Mix]`, "Playlists/[Mix]", runtime.GOOS == "windows"), + Entry("backslash separator before a bracket range", `Playlists\[ab]`, "Playlists/a", runtime.GOOS == "windows"), + Entry("backslash separator before a bracket range excludes others", `Playlists\[ab]`, "Playlists/c", false), + Entry("escaped braces", `\{Mix\}`, "{Mix}", true), + Entry("escaped braces exclude a plain folder", `\{Mix\}`, "Mix", false), + ) }) }) diff --git a/core/playlists/playlists.go b/core/playlists/playlists.go index 9bf03a726..7d3b5bca8 100644 --- a/core/playlists/playlists.go +++ b/core/playlists/playlists.go @@ -4,9 +4,8 @@ import ( "context" "io" "os" - "path/filepath" + "path" "strconv" - "strings" "github.com/bmatcuk/doublestar/v4" "github.com/deluan/rest" @@ -77,11 +76,10 @@ func InPath(folder model.Folder) bool { if conf.Server.PlaylistsPath == "" { return true } - rel, _ := filepath.Rel(folder.LibraryPath, folder.AbsolutePath()) - // doublestar splits only on / and treats \ as an escape, so normalize OS separators first. - rel = filepath.ToSlash(rel) - for path := range strings.SplitSeq(conf.Server.PlaylistsPath, string(filepath.ListSeparator)) { - if match, _ := doublestar.Match(filepath.ToSlash(path), rel); match { + // Folder paths are already slash-separated and relative to the library, as doublestar expects + rel := path.Join(folder.Path, folder.Name) + for _, pattern := range conf.PlaylistsPathPatterns() { + if match, _ := doublestar.Match(pattern, rel); match { return true } } diff --git a/scanner/scanner_playlists_path_test.go b/scanner/scanner_playlists_path_test.go new file mode 100644 index 000000000..0985f4654 --- /dev/null +++ b/scanner/scanner_playlists_path_test.go @@ -0,0 +1,180 @@ +package scanner_test + +import ( + "context" + "os" + "path" + "path/filepath" + "runtime" + "slices" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/persistence" + "github.com/navidrome/navidrome/scanner" + "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Scans a real library folder on the native filesystem (so Windows paths go through the OS path +// handling) and checks what reaches the DB in phase 1 (num_playlists) and phase 4 (playlists). +var _ = Describe("Scanner - PlaylistsPath", Ordered, ContinueOnFailure, func() { + var ctx context.Context + var ds model.DataStore + var s model.Scanner + var libPath string + + BeforeAll(func() { + ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "123", IsAdmin: true}) + // The DB stays open until the suite ends, and Windows can't delete an open file + tmpDir, err := os.MkdirTemp("", "scanner-playlists-path-test") + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(func() { _ = os.RemoveAll(tmpDir) }) + conf.Server.DbPath = filepath.Join(tmpDir, "test-scanner.db?_journal_mode=WAL") + db.Db().SetMaxOpenConns(1) + }) + + writeNSP := func(relPath, name string) { + GinkgoHelper() + full := filepath.Join(libPath, filepath.FromSlash(relPath)) + Expect(os.MkdirAll(filepath.Dir(full), 0755)).To(Succeed()) + nsp := `{"name": "` + name + `", "all": [{"is": {"loved": true}}]}` + Expect(os.WriteFile(full, []byte(nsp), 0600)).To(Succeed()) + } + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + libPath = GinkgoT().TempDir() + conf.Server.MusicFolder = libPath + conf.Server.DevExternalScanner = false + conf.Server.AutoImportPlaylists = true + + db.Init(ctx) + DeferCleanup(func() { + Expect(tests.ClearDB()).To(Succeed()) + }) + ds = persistence.New(db.Db()) + + adminUser := model.User{ID: "123", UserName: "admin", Name: "Admin User", IsAdmin: true, NewPassword: "password"} + Expect(ds.User().Put(ctx, &adminUser)).To(Succeed()) + + lib := model.Library{ID: 1, Name: "Native Library", Path: libPath} + Expect(ds.Library().Put(ctx, &lib)).To(Succeed()) + + s = scanner.New(ctx, ds, events.NoopBroker(), + playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance()) + }) + + scan := func(fullScan bool) { + GinkgoHelper() + _, err := s.ScanAll(ctx, fullScan) + Expect(err).ToNot(HaveOccurred()) + } + + // One map, so a failure shows both the phase 1 and the phase 4 results + results := func() map[string][]string { + GinkgoHelper() + folders, err := ds.Folder().GetAll(ctx) + Expect(err).ToNot(HaveOccurred()) + var withPlaylists []string + for _, f := range folders { + if f.NumPlaylists > 0 { + withPlaylists = append(withPlaylists, path.Join(f.Path, f.Name)) + } + } + all, err := ds.Playlist().GetAll(ctx) + Expect(err).ToNot(HaveOccurred()) + var names []string + for _, p := range all { + Expect(p.Path).To(HavePrefix(libPath)) + names = append(names, p.Name) + } + return map[string][]string{ + "phase 1: folders with num_playlists > 0": slices.Sorted(slices.Values(withPlaylists)), + "phase 4: imported playlists": slices.Sorted(slices.Values(names)), + } + } + + expectResults := func(folders, names []string) { + GinkgoHelper() + Expect(results()).To(Equal(map[string][]string{ + "phase 1: folders with num_playlists > 0": slices.Sorted(slices.Values(folders)), + "phase 4: imported playlists": slices.Sorted(slices.Values(names)), + })) + } + + onWindows := func(values ...string) []string { + if runtime.GOOS == "windows" { + return values + } + return nil + } + + // Phase 4 keeps its folder cursor open while importing, so with the suite's single DB connection + // a scan stalls past ~6 playlist folders. Each library below stays under that. + Describe("selecting nested folders", func() { + BeforeEach(func() { + writeNSP("Root.nsp", "Root") + writeNSP("Playlists/navidrome/Rock.nsp", "Rock") + writeNSP("Playlists/navidrome/Deep/Nested.nsp", "Nested") + writeNSP("Playlists/other/Other.nsp", "Other") + }) + + DescribeTable("imports only playlists inside PlaylistsPath", + func(pattern string, folders, names []string) { + conf.Server.PlaylistsPath = pattern + scan(true) + expectResults(folders, names) + }, + Entry("empty (default) imports everything", "", + []string{".", "Playlists/navidrome", "Playlists/navidrome/Deep", "Playlists/other"}, + []string{"Root", "Rock", "Nested", "Other"}), + Entry("nested folder", "Playlists/navidrome", + []string{"Playlists/navidrome"}, []string{"Rock"}), + Entry("root and a nested ** pattern", "."+string(filepath.ListSeparator)+"Playlists/navidrome/**", + []string{".", "Playlists/navidrome", "Playlists/navidrome/Deep"}, []string{"Root", "Rock", "Nested"}), + Entry("non-matching pattern imports nothing", "Music/**", nil, nil), + // Backslash is a path separator on Windows (except in escaped brackets or braces), an escape elsewhere + Entry("backslash nested folder (issue #6276 config)", `Playlists\navidrome`, + onWindows("Playlists/navidrome"), onWindows("Rock")), + Entry("backslash separator before braces", `Playlists\{navidrome,other}`, + onWindows("Playlists/navidrome", "Playlists/other"), onWindows("Rock", "Other")), + ) + }) + + Describe("selecting folders with brackets in their names", func() { + BeforeEach(func() { + writeNSP("[Mix]/Mix.nsp", "Mix") + writeNSP("Mix/Plain.nsp", "Plain") + writeNSP("Playlists/[Mix]/NestedMix.nsp", "NestedMix") + writeNSP("{Mix}/Braces.nsp", "Braces") + }) + + DescribeTable("imports only playlists inside PlaylistsPath", + func(pattern string, folders, names []string) { + conf.Server.PlaylistsPath = pattern + scan(true) + expectResults(folders, names) + }, + Entry("brackets: empty (default) imports everything", "", + []string{"[Mix]", "Mix", "Playlists/[Mix]", "{Mix}"}, []string{"Mix", "Plain", "NestedMix", "Braces"}), + Entry("brackets: escaped, top-level", `\[Mix\]`, []string{"[Mix]"}, []string{"Mix"}), + Entry("brackets: escaped, nested", `Playlists/\[Mix\]`, []string{"Playlists/[Mix]"}, []string{"NestedMix"}), + Entry("brackets: character class literal", `[[]Mix]`, []string{"[Mix]"}, []string{"Mix"}), + Entry("brackets: unescaped brackets are a character class", `[Mix]`, nil, nil), + Entry("brackets: backslash before escaped brackets, nested", `Playlists\\[Mix\]`, + onWindows("Playlists/[Mix]"), onWindows("NestedMix")), + Entry("brackets: backslash separator before a character class", `Playlists\[[]Mix]`, + onWindows("Playlists/[Mix]"), onWindows("NestedMix")), + Entry("brackets: escaped braces", `\{Mix\}`, []string{"{Mix}"}, []string{"Braces"}), + ) + }) +})