mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
Merge f580be7a36dc3266dea800dda9ed3795c274ed14 into 3867fab4da6ea7142bfc6374c94b34a708e03b4e
This commit is contained in:
commit
681020ef3a
@ -134,7 +134,7 @@ func loadDir(ctx context.Context, job *scanJob, dirPath string, checker *IgnoreC
|
||||
log.Warn(ctx, "Scanner: Invalid symlink", "dir", entryPath, err)
|
||||
continue
|
||||
}
|
||||
if isIgnoredEntry(entry.Name(), isDir) {
|
||||
if isIgnoredEntry(entryPath, isDir) {
|
||||
continue
|
||||
}
|
||||
if isDir && isDirReadable(ctx, job.fs, entryPath) {
|
||||
@ -286,7 +286,7 @@ func isDirReadable(ctx context.Context, fsys fs.FS, dirPath string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// List of special directories to ignore
|
||||
// List of special directories to ignore anywhere in the library tree
|
||||
var ignoredDirs = []string{
|
||||
"$RECYCLE.BIN",
|
||||
"#snapshot",
|
||||
@ -294,26 +294,51 @@ var ignoredDirs = []string{
|
||||
"@Recently-Snapshot",
|
||||
".git",
|
||||
".streams",
|
||||
}
|
||||
|
||||
// List of special directories to ignore only when they sit directly at the library
|
||||
// root. These are created by the filesystem itself at the root of a mount point
|
||||
// (mke2fs creates lost+found), which for a music library is the library root. Deeper
|
||||
// in the tree the same name is just a regular folder, and a valid album title at that
|
||||
// (e.g. Jonathan Coulton's "lost+found"), so it must be scanned normally.
|
||||
var rootOnlyIgnoredDirs = []string{
|
||||
"lost+found",
|
||||
}
|
||||
|
||||
// isIgnoredEntry returns true if a directory entry with the given name should be
|
||||
// skipped during scanning. It centralizes all name- and type-based ignore policy:
|
||||
// - special system directories in ignoredDirs are always ignored;
|
||||
// isIgnoredEntry returns true if the directory entry at the given library-relative
|
||||
// path should be skipped during scanning. It centralizes all name- and type-based
|
||||
// ignore policy:
|
||||
// - special system directories are ignored, either anywhere in the tree or only
|
||||
// at the library root, see isDirIgnored;
|
||||
// - dot-prefixed files are always ignored;
|
||||
// - dot-prefixed folders are ignored unless Scanner.IgnoreDotFolders is disabled,
|
||||
// allowing albums like ".Hack Sign" to be scanned when the option is off.
|
||||
func isIgnoredEntry(name string, isDir bool) bool {
|
||||
if isDir && isDirIgnored(name) {
|
||||
func isIgnoredEntry(entryPath string, isDir bool) bool {
|
||||
if isDir && isDirIgnored(entryPath) {
|
||||
return true
|
||||
}
|
||||
return isDotEntry(name) && (!isDir || conf.Server.Scanner.IgnoreDotFolders)
|
||||
return isDotEntry(entryName(entryPath)) && (!isDir || conf.Server.Scanner.IgnoreDotFolders)
|
||||
}
|
||||
|
||||
// isDirIgnored returns true if the directory name is in the explicit ignoredDirs
|
||||
// blocklist. Used both while walking the tree and by the file watcher.
|
||||
func isDirIgnored(name string) bool {
|
||||
return slices.ContainsFunc(ignoredDirs, func(s string) bool { return strings.EqualFold(s, name) })
|
||||
// isDirIgnored returns true if the directory at the given library-relative path is in
|
||||
// one of the explicit blocklists: ignoredDirs matches at any depth, rootOnlyIgnoredDirs
|
||||
// only when the directory is a direct child of the library root. Used both while
|
||||
// walking the tree and by the file watcher.
|
||||
func isDirIgnored(dirPath string) bool {
|
||||
name := entryName(dirPath)
|
||||
if slices.ContainsFunc(ignoredDirs, func(s string) bool { return strings.EqualFold(s, name) }) {
|
||||
return true
|
||||
}
|
||||
if path.Dir(path.Clean(filepath.ToSlash(dirPath))) != "." {
|
||||
return false
|
||||
}
|
||||
return slices.ContainsFunc(rootOnlyIgnoredDirs, func(s string) bool { return strings.EqualFold(s, name) })
|
||||
}
|
||||
|
||||
// entryName returns the last element of a library-relative path, accepting both slash-
|
||||
// and OS-separated paths, as the watcher works with the latter.
|
||||
func entryName(entryPath string) string {
|
||||
return path.Base(filepath.ToSlash(entryPath))
|
||||
}
|
||||
|
||||
// isDotEntry returns true only for names with exactly one leading dot (the
|
||||
|
||||
@ -26,6 +26,23 @@ var _ = Describe("walk_dir_tree", func() {
|
||||
ctx context.Context
|
||||
)
|
||||
|
||||
// Helper function to call walkDirTree and collect folders from the results channel
|
||||
getFolders := func() map[string]*folderEntry {
|
||||
results, err := walkDirTree(ctx, job)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
folders := map[string]*folderEntry{}
|
||||
g := errgroup.Group{}
|
||||
g.Go(func() error {
|
||||
for folder := range results {
|
||||
folders[folder.path] = folder
|
||||
}
|
||||
return nil
|
||||
})
|
||||
_ = g.Wait()
|
||||
return folders
|
||||
}
|
||||
|
||||
Context("full library", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
@ -61,23 +78,6 @@ var _ = Describe("walk_dir_tree", func() {
|
||||
}
|
||||
})
|
||||
|
||||
// Helper function to call walkDirTree and collect folders from the results channel
|
||||
getFolders := func() map[string]*folderEntry {
|
||||
results, err := walkDirTree(ctx, job)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
folders := map[string]*folderEntry{}
|
||||
g := errgroup.Group{}
|
||||
g.Go(func() error {
|
||||
for folder := range results {
|
||||
folders[folder.path] = folder
|
||||
}
|
||||
return nil
|
||||
})
|
||||
_ = g.Wait()
|
||||
return folders
|
||||
}
|
||||
|
||||
DescribeTable("symlink handling",
|
||||
func(followSymlinks bool, expectedFolderCount int) {
|
||||
conf.Server.Scanner.FollowSymlinks = followSymlinks
|
||||
@ -150,6 +150,37 @@ var _ = Describe("walk_dir_tree", func() {
|
||||
)
|
||||
})
|
||||
|
||||
Context("with a lost+found folder", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
ctx = GinkgoT().Context()
|
||||
fsys = &mockMusicFS{
|
||||
FS: fstest.MapFS{
|
||||
"lost+found/orphan.mp3": {},
|
||||
"Jonathan Coulton/lost+found/re-your-brains.mp3": {},
|
||||
},
|
||||
}
|
||||
job = &scanJob{
|
||||
fs: fsys,
|
||||
lib: model.Library{Path: "/music"},
|
||||
}
|
||||
})
|
||||
|
||||
It("skips lost+found at the library root, where the filesystem creates it", func() {
|
||||
Expect(getFolders()).ToNot(HaveKey("lost+found"))
|
||||
})
|
||||
|
||||
It("scans a lost+found album folder nested in the library", func() {
|
||||
folders := getFolders()
|
||||
|
||||
Expect(folders).To(HaveKey("Jonathan Coulton/lost+found"))
|
||||
Expect(folders["Jonathan Coulton/lost+found"].audioFiles).To(SatisfyAll(
|
||||
HaveLen(1),
|
||||
HaveKey("re-your-brains.mp3"),
|
||||
))
|
||||
})
|
||||
})
|
||||
|
||||
Context("with target folders", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
@ -554,8 +585,8 @@ var _ = Describe("walk_dir_tree", func() {
|
||||
|
||||
Describe("isDirIgnored", func() {
|
||||
DescribeTable("returns expected result",
|
||||
func(dirName string, expected bool) {
|
||||
Expect(isDirIgnored(dirName)).To(Equal(expected))
|
||||
func(dirPath string, expected bool) {
|
||||
Expect(isDirIgnored(dirPath)).To(Equal(expected))
|
||||
},
|
||||
Entry("normal dir", "empty_folder", false),
|
||||
Entry("dot-prefixed album dir", ".Hack Sign Original Soundtrack", false),
|
||||
@ -564,6 +595,12 @@ var _ = Describe("walk_dir_tree", func() {
|
||||
Entry("dir starting with ellipsis", "...unhidden_folder", false),
|
||||
Entry("recycle bin", "$Recycle.Bin", true),
|
||||
Entry("snapshot dir", "#snapshot", true),
|
||||
Entry("blocklisted dir nested in the library", "rock/Artist/.git", true),
|
||||
Entry("lost+found at the library root", "lost+found", true),
|
||||
Entry("lost+found at the library root, other case", "Lost+Found", true),
|
||||
Entry("lost+found album nested in the library", "Jonathan Coulton/lost+found", false),
|
||||
Entry("lost+found album nested deeper", "rock/Jonathan Coulton/lost+found", false),
|
||||
Entry("normal dir nested in the library", "Jonathan Coulton/Thing a Week", false),
|
||||
)
|
||||
})
|
||||
|
||||
@ -573,9 +610,9 @@ var _ = Describe("walk_dir_tree", func() {
|
||||
})
|
||||
|
||||
DescribeTable("with IgnoreDotFolders enabled (default)",
|
||||
func(name string, isDir, expected bool) {
|
||||
func(entryPath string, isDir, expected bool) {
|
||||
conf.Server.Scanner.IgnoreDotFolders = true
|
||||
Expect(isIgnoredEntry(name, isDir)).To(Equal(expected))
|
||||
Expect(isIgnoredEntry(entryPath, isDir)).To(Equal(expected))
|
||||
},
|
||||
Entry("normal dir", "Album", true, false),
|
||||
Entry("normal file", "track.mp3", false, false),
|
||||
@ -583,18 +620,25 @@ var _ = Describe("walk_dir_tree", func() {
|
||||
Entry("dot file", ".hidden.mp3", false, true),
|
||||
Entry("blocklisted dir", ".git", true, true),
|
||||
Entry("ellipsis dir", "...unhidden", true, false),
|
||||
Entry("nested dot folder", "rock/.Hack Sign Original Soundtrack", true, true),
|
||||
Entry("nested dot file", "rock/Album/.hidden.mp3", false, true),
|
||||
Entry("lost+found at the library root", "lost+found", true, true),
|
||||
Entry("nested lost+found album", "Jonathan Coulton/lost+found", true, false),
|
||||
Entry("file named lost+found at the library root", "lost+found", false, false),
|
||||
)
|
||||
|
||||
DescribeTable("with IgnoreDotFolders disabled",
|
||||
func(name string, isDir, expected bool) {
|
||||
func(entryPath string, isDir, expected bool) {
|
||||
conf.Server.Scanner.IgnoreDotFolders = false
|
||||
Expect(isIgnoredEntry(name, isDir)).To(Equal(expected))
|
||||
Expect(isIgnoredEntry(entryPath, isDir)).To(Equal(expected))
|
||||
},
|
||||
Entry("normal dir", "Album", true, false),
|
||||
Entry("normal file", "track.mp3", false, false),
|
||||
Entry("dot folder is allowed", ".Hack Sign Original Soundtrack", true, false),
|
||||
Entry("dot file is still ignored", ".hidden.mp3", false, true),
|
||||
Entry("blocklisted dir still ignored", ".git", true, true),
|
||||
Entry("lost+found at the library root still ignored", "lost+found", true, true),
|
||||
Entry("nested lost+found album is allowed", "Jonathan Coulton/lost+found", true, false),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
@ -244,31 +245,31 @@ func (w *watcher) processLibraryEvents(ctx context.Context, lib *model.Library,
|
||||
case <-ctx.Done():
|
||||
log.Debug(ctx, "Watcher stopped due to context cancellation", "libraryID", lib.ID, "name", lib.Name)
|
||||
return nil
|
||||
case path := <-events:
|
||||
path, err := filepath.Rel(absLibPath, path)
|
||||
case changed := <-events:
|
||||
p, err := filepath.Rel(absLibPath, changed)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error getting relative path", "libraryID", lib.ID, "absolutePath", absLibPath, "path", path, err)
|
||||
log.Error(ctx, "Error getting relative path", "libraryID", lib.ID, "absolutePath", absLibPath, "path", changed, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if isIgnoredPath(ctx, fsys, path) {
|
||||
log.Trace(ctx, "Ignoring change", "libraryID", lib.ID, "path", path)
|
||||
if isIgnoredPath(ctx, fsys, p) {
|
||||
log.Trace(ctx, "Ignoring change", "libraryID", lib.ID, "path", p)
|
||||
continue
|
||||
}
|
||||
log.Trace(ctx, "Detected change", "libraryID", lib.ID, "path", path, "absoluteLibPath", absLibPath)
|
||||
log.Trace(ctx, "Detected change", "libraryID", lib.ID, "path", p, "absoluteLibPath", absLibPath)
|
||||
|
||||
// Check if the original path (before resolution) matches .ndignore patterns
|
||||
// This is crucial for deleted folders - if a deleted folder matches .ndignore,
|
||||
// we should ignore it BEFORE resolveFolderPath walks up to the parent
|
||||
if w.shouldIgnoreFolderPath(ctx, fsys, path) {
|
||||
log.Debug(ctx, "Ignoring change matching .ndignore pattern", "libraryID", lib.ID, "path", path)
|
||||
if w.shouldIgnoreFolderPath(ctx, fsys, p) {
|
||||
log.Debug(ctx, "Ignoring change matching .ndignore pattern", "libraryID", lib.ID, "path", p)
|
||||
continue
|
||||
}
|
||||
|
||||
// Find the folder to scan - validate path exists as directory, walk up if needed
|
||||
folderPath := resolveFolderPath(fsys, path)
|
||||
folderPath := resolveFolderPath(fsys, p)
|
||||
// Double-check after resolution in case the resolved path is different and also matches patterns
|
||||
if folderPath != path && w.shouldIgnoreFolderPath(ctx, fsys, folderPath) {
|
||||
if folderPath != p && w.shouldIgnoreFolderPath(ctx, fsys, folderPath) {
|
||||
log.Trace(ctx, "Ignoring change in folder matching .ndignore pattern", "libraryID", lib.ID, "folderPath", folderPath)
|
||||
continue
|
||||
}
|
||||
@ -282,13 +283,13 @@ func (w *watcher) processLibraryEvents(ctx context.Context, lib *model.Library,
|
||||
// resolveFolderPath takes a path (which may be a file or directory) and returns
|
||||
// the folder path to scan. If the path is a file, it walks up to find the parent
|
||||
// directory. Returns empty string if the path should scan the library root.
|
||||
func resolveFolderPath(fsys fs.FS, path string) string {
|
||||
func resolveFolderPath(fsys fs.FS, p string) string {
|
||||
// Handle root paths immediately
|
||||
if path == "." || path == "" {
|
||||
if p == "." || p == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
folderPath := path
|
||||
folderPath := p
|
||||
for {
|
||||
info, err := fs.Stat(fsys, folderPath)
|
||||
if err == nil && info.IsDir() {
|
||||
@ -320,16 +321,18 @@ func (w *watcher) shouldIgnoreFolderPath(ctx context.Context, fsys storage.Music
|
||||
return checker.ShouldIgnore(ctx, folderPath)
|
||||
}
|
||||
|
||||
func isIgnoredPath(_ context.Context, _ fs.FS, path string) bool {
|
||||
_, name := filepath.Split(path)
|
||||
// isIgnoredPath reports whether a change at the given library-relative path should not
|
||||
// trigger a scan.
|
||||
func isIgnoredPath(_ context.Context, _ fs.FS, p string) bool {
|
||||
_, name := filepath.Split(p)
|
||||
// A change anywhere inside an ignored directory (a dot-folder when
|
||||
// Scanner.IgnoreDotFolders is enabled, or a special system folder) must not
|
||||
// trigger a scan, even for media files: the scan would skip it anyway.
|
||||
if isUnderIgnoredDir(path) {
|
||||
if isUnderIgnoredDir(p) {
|
||||
return true
|
||||
}
|
||||
switch {
|
||||
case model.IsAudioFile(path), model.IsValidPlaylist(path), model.IsImageFile(path):
|
||||
case model.IsAudioFile(p), model.IsValidPlaylist(p), model.IsImageFile(p):
|
||||
// A media file is normally not ignored, but a dot-prefixed one (e.g.
|
||||
// ".hidden.mp3") is always skipped by the scanner, so don't scan for it.
|
||||
return isDotEntry(name)
|
||||
@ -339,15 +342,22 @@ func isIgnoredPath(_ context.Context, _ fs.FS, path string) bool {
|
||||
// As it can be a deletion and not a change, we cannot reliably know if the
|
||||
// path is a file or directory. But at this point, we can assume it's a
|
||||
// directory. If it's a file, it would be ignored anyway.
|
||||
return isIgnoredEntry(name, true)
|
||||
return isIgnoredEntry(p, true)
|
||||
}
|
||||
|
||||
// isUnderIgnoredDir returns true if any parent directory component of the given
|
||||
// path is an ignored directory, reusing the same policy as the scanner walk.
|
||||
func isUnderIgnoredDir(path string) bool {
|
||||
dir, _ := filepath.Split(path)
|
||||
// library-relative path is an ignored directory, reusing the same policy as the
|
||||
// scanner walk. Components are rebuilt into their full library-relative path, as the
|
||||
// policy depends on how deep the folder sits, not just on its name.
|
||||
func isUnderIgnoredDir(p string) bool {
|
||||
dir, _ := filepath.Split(p)
|
||||
var parent string
|
||||
for part := range strings.SplitSeq(filepath.ToSlash(dir), "/") {
|
||||
if part != "" && isIgnoredEntry(part, true) {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
parent = path.Join(parent, part)
|
||||
if isIgnoredEntry(parent, true) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@ -460,6 +460,10 @@ var _ = Describe("isIgnoredPath", func() {
|
||||
Entry("dot-folder itself", "rock/.Hidden Album", true),
|
||||
Entry("normal folder itself", "rock/Album", false),
|
||||
Entry(".DS_Store file", "rock/Album/.DS_Store", true),
|
||||
Entry("lost+found at the library root", "lost+found", true),
|
||||
Entry("media file inside lost+found at the library root", "lost+found/orphan.mp3", true),
|
||||
Entry("nested lost+found album folder", "Jonathan Coulton/lost+found", false),
|
||||
Entry("media file inside a nested lost+found album", "Jonathan Coulton/lost+found/track.mp3", false),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user