Merge f580be7a36dc3266dea800dda9ed3795c274ed14 into 3867fab4da6ea7142bfc6374c94b34a708e03b4e

This commit is contained in:
Junker der Provinz 2026-08-30 15:41:58 +00:00 committed by GitHub
commit 681020ef3a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 140 additions and 57 deletions

View File

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

View File

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

View File

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

View File

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