fix(playlists): keep escaped glob brackets in PlaylistsPath on Windows (#6276)

Converting every '\' to '/' broke escaped literals such as '\[Mix\]',
which matched a real "[Mix]" folder before. On Windows, '\' is now a
path separator except for an escaped pair ('\[...\]', '\{...\}') or a
lone '\]' / '\}', so 'Playlists\navidrome', 'Playlists\{rock,jazz}',
'Playlists\[ab]' and '\[Mix\]' all work. Other OSes keep the pattern
as is. A separator right before an escaped pair must be written as '/'
or '\\' ('Playlists/\[Mix\]').

InPath now matches the folder's slash-separated library path
(path.Join(Path, Name)) instead of a filepath.Rel/ToSlash round trip,
and gets its patterns from conf.PlaylistsPathPatterns, which the
config validation can share.

Tests cover the conversion, InPath with scanner-built folders, and
scans of real folders (nested paths, brackets, braces, controls).
This commit is contained in:
Deluan 2026-10-07 10:10:32 -04:00
commit cbf55d932d
6 changed files with 307 additions and 7 deletions

View file

@ -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, "")

View file

@ -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()

View file

@ -36,3 +36,5 @@ func SetLogFatal(f func(...any)) func() {
var UnknownConfigKeys = unknownConfigKeys
var SuggestOptions = suggestOptions
var ToGlobPattern = toGlobPattern

View file

@ -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),
)
})
})

View file

@ -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
}
}

View file

@ -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"}),
)
})
})