mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 02:17:25 +02:00
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:
parent
f8674115d2
commit
cbf55d932d
6 changed files with 307 additions and 7 deletions
|
|
@ -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, "")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -36,3 +36,5 @@ func SetLogFatal(f func(...any)) func() {
|
|||
var UnknownConfigKeys = unknownConfigKeys
|
||||
|
||||
var SuggestOptions = suggestOptions
|
||||
|
||||
var ToGlobPattern = toGlobPattern
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
180
scanner/scanner_playlists_path_test.go
Normal file
180
scanner/scanner_playlists_path_test.go
Normal 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"}),
|
||||
)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue