feat(watcher): implement path ignoring based on .ndignore patterns

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2025-11-10 22:46:48 -05:00
parent b361837f50
commit 3cbbb440c2
3 changed files with 168 additions and 36 deletions

View File

@ -43,7 +43,7 @@ func loadSpecificFolders(ctx context.Context, job *scanJob, targetFolders []stri
}
// Load ignore patterns from parent directories up to this folder
ignorePatterns := loadIgnorePatternsForPath(ctx, job.fs, folderPath)
ignorePatterns := loadIgnoredPatternsForPath(ctx, job.fs, folderPath)
// Load only this specific folder (no recursion)
folder, _, err := loadDir(ctx, job, folderPath, ignorePatterns)
@ -65,8 +65,8 @@ func loadSpecificFolders(ctx context.Context, job *scanJob, targetFolders []stri
return results, nil
}
// loadIgnorePatternsForPath loads all .ndignore patterns from the root down to the specified path
func loadIgnorePatternsForPath(ctx context.Context, fsys fs.FS, targetPath string) []string {
// loadIgnoredPatternsForPath loads all .ndignore patterns from the root down to the specified path
func loadIgnoredPatternsForPath(ctx context.Context, fsys fs.FS, targetPath string) []string {
var patterns []string
currentPath := "."
@ -90,33 +90,7 @@ func loadIgnorePatternsForPath(ctx context.Context, fsys fs.FS, targetPath strin
return patterns
}
func walkFolder(ctx context.Context, job *scanJob, currentFolder string, ignorePatterns []string, results chan<- *folderEntry) error {
ignorePatterns = loadIgnoredPatterns(ctx, job.fs, currentFolder, ignorePatterns)
folder, children, err := loadDir(ctx, job, currentFolder, ignorePatterns)
if err != nil {
log.Warn(ctx, "Scanner: Error loading dir. Skipping", "path", currentFolder, err)
return nil
}
for _, c := range children {
err := walkFolder(ctx, job, c, ignorePatterns, results)
if err != nil {
return err
}
}
dir := path.Clean(currentFolder)
log.Trace(ctx, "Scanner: Found directory", " path", dir, "audioFiles", maps.Keys(folder.audioFiles),
"images", maps.Keys(folder.imageFiles), "playlists", folder.numPlaylists, "imagesUpdatedAt", folder.imagesUpdatedAt,
"updTime", folder.updTime, "modTime", folder.modTime, "numChildren", len(children))
folder.path = dir
folder.elapsed.Start()
results <- folder
return nil
}
// loadIgnoredPatterns loads .ndignore patterns from the specified folder and combines them with currentPatterns
func loadIgnoredPatterns(ctx context.Context, fsys fs.FS, currentFolder string, currentPatterns []string) []string {
ignoreFilePath := path.Join(currentFolder, consts.ScanIgnoreFile)
var newPatterns []string
@ -153,6 +127,33 @@ func loadIgnoredPatterns(ctx context.Context, fsys fs.FS, currentFolder string,
return append(combinedPatterns, newPatterns...)
}
func walkFolder(ctx context.Context, job *scanJob, currentFolder string, ignorePatterns []string, results chan<- *folderEntry) error {
ignorePatterns = loadIgnoredPatterns(ctx, job.fs, currentFolder, ignorePatterns)
folder, children, err := loadDir(ctx, job, currentFolder, ignorePatterns)
if err != nil {
log.Warn(ctx, "Scanner: Error loading dir. Skipping", "path", currentFolder, err)
return nil
}
for _, c := range children {
err := walkFolder(ctx, job, c, ignorePatterns, results)
if err != nil {
return err
}
}
dir := path.Clean(currentFolder)
log.Trace(ctx, "Scanner: Found directory", " path", dir, "audioFiles", maps.Keys(folder.audioFiles),
"images", maps.Keys(folder.imageFiles), "playlists", folder.numPlaylists, "imagesUpdatedAt", folder.imagesUpdatedAt,
"updTime", folder.updTime, "modTime", folder.modTime, "numChildren", len(children))
folder.path = dir
folder.elapsed.Start()
results <- folder
return nil
}
func loadDir(ctx context.Context, job *scanJob, dirPath string, ignorePatterns []string) (folder *folderEntry, children []string, err error) {
folder = newFolderEntry(job, dirPath)

View File

@ -13,6 +13,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/singleton"
ignore "github.com/sabhiram/go-gitignore"
)
type Watcher interface {
@ -241,6 +242,12 @@ func (w *watcher) watchLibrary(ctx context.Context, lib *model.Library) error {
// Find the folder to scan - validate path exists as directory, walk up if needed
folderPath := resolveFolderPath(fsys, path)
// Check if the folder should be ignored based on .ndignore patterns
if shouldIgnorePath(ctx, fsys, folderPath) {
log.Trace(ctx, "Ignoring change in folder matching .ndignore pattern", "libraryID", lib.ID, "folderPath", folderPath)
continue
}
// Notify the main watcher of changes
select {
case w.watcherNotify <- scanNotification{Library: lib, FolderPath: folderPath}:
@ -297,3 +304,26 @@ func isIgnoredPath(_ context.Context, _ fs.FS, path string) bool {
// But at this point, we can assume it's a directory. If it's a file, it would be ignored anyway
return isDirIgnored(baseDir)
}
// shouldIgnorePath checks if the given path should be ignored based on .ndignore patterns.
// It loads all .ndignore files from the root down to the path and returns true if the path
// matches any ignore pattern. This function is suitable for checking paths without recursion,
// such as in the watcher.
func shouldIgnorePath(ctx context.Context, fsys fs.FS, relPath string) bool {
// Handle root/empty path - never ignore
if relPath == "" || relPath == "." {
return false
}
// Load ignore patterns from root to the target path
patterns := loadIgnoredPatternsForPath(ctx, fsys, relPath)
// If no patterns, nothing to ignore
if len(patterns) == 0 {
return false
}
// Compile and check
matcher := ignore.CompileIgnoreLines(patterns...)
return isScanIgnored(ctx, matcher, relPath)
}

View File

@ -146,8 +146,8 @@ var _ = Describe("Watcher", func() {
// Send first notification
w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"}
// Wait half the watcher wait time
time.Sleep(25 * time.Millisecond)
// Wait a bit less than half the watcher wait time to ensure timer doesn't fire
time.Sleep(20 * time.Millisecond)
// No scan should have been triggered yet
Expect(mockScanner.GetScanFoldersCallCount()).To(Equal(0))
@ -155,14 +155,14 @@ var _ = Describe("Watcher", func() {
// Send another notification (resets timer)
w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"}
// Wait half the watcher wait time again
time.Sleep(25 * time.Millisecond)
// Wait a bit less than half the watcher wait time again
time.Sleep(20 * time.Millisecond)
// Still no scan
Expect(mockScanner.GetScanFoldersCallCount()).To(Equal(0))
// Wait for full timer to expire after last notification
time.Sleep(50 * time.Millisecond)
// Wait for full timer to expire after last notification (plus margin)
time.Sleep(60 * time.Millisecond)
// Now scan should have been triggered
Eventually(func() int {
@ -279,6 +279,107 @@ var _ = Describe("Watcher", func() {
})
})
var _ = Describe("shouldIgnorePath", func() {
var ctx context.Context
var mockFS fs.FS
BeforeEach(func() {
ctx = context.Background()
// Create a mock filesystem with .ndignore files
mockFS = fstest.MapFS{
// Root .ndignore ignoring "temp/*"
".ndignore": &fstest.MapFile{Data: []byte("temp/*\n*.log\n")},
// Normal directories
"music": &fstest.MapFile{Mode: fs.ModeDir},
"music/artist1": &fstest.MapFile{Mode: fs.ModeDir},
"music/artist1/song.mp3": &fstest.MapFile{Data: []byte("audio")},
// Temp directory (should be ignored)
"temp": &fstest.MapFile{Mode: fs.ModeDir},
"temp/cache": &fstest.MapFile{Mode: fs.ModeDir},
"temp/cache/file.mp3": &fstest.MapFile{Data: []byte("audio")},
// Directory with hierarchical .ndignore
"project": &fstest.MapFile{Mode: fs.ModeDir},
"project/.ndignore": &fstest.MapFile{Data: []byte("drafts\n")},
"project/final": &fstest.MapFile{Mode: fs.ModeDir},
"project/final/album.mp3": &fstest.MapFile{Data: []byte("audio")},
"project/drafts": &fstest.MapFile{Mode: fs.ModeDir},
"project/drafts/test.mp3": &fstest.MapFile{Data: []byte("audio")},
// Directory with empty .ndignore (should ignore everything)
"empty": &fstest.MapFile{Mode: fs.ModeDir},
"empty/.ndignore": &fstest.MapFile{Data: []byte("")},
"empty/subdir": &fstest.MapFile{Mode: fs.ModeDir},
// Log file at root level (should be ignored by *.log pattern)
"debug.log": &fstest.MapFile{Data: []byte("logs")},
}
})
It("does not ignore paths without .ndignore patterns", func() {
result := shouldIgnorePath(ctx, mockFS, "music/artist1")
Expect(result).To(BeFalse())
})
It("ignores paths matching root .ndignore patterns", func() {
result := shouldIgnorePath(ctx, mockFS, "temp/cache")
Expect(result).To(BeTrue())
})
It("ignores log files matching *.log pattern", func() {
result := shouldIgnorePath(ctx, mockFS, "debug.log")
Expect(result).To(BeTrue())
})
It("applies hierarchical .ndignore patterns", func() {
// project/drafts should be ignored by project/.ndignore
result := shouldIgnorePath(ctx, mockFS, "project/drafts")
Expect(result).To(BeTrue())
// project/final should NOT be ignored
result = shouldIgnorePath(ctx, mockFS, "project/final")
Expect(result).To(BeFalse())
})
It("ignores directories with empty .ndignore file", func() {
result := shouldIgnorePath(ctx, mockFS, "empty/subdir")
Expect(result).To(BeTrue())
})
It("does not ignore root or empty paths", func() {
Expect(shouldIgnorePath(ctx, mockFS, "")).To(BeFalse())
Expect(shouldIgnorePath(ctx, mockFS, ".")).To(BeFalse())
})
It("combines patterns from multiple .ndignore files", func() {
// Create a more complex hierarchy
complexFS := fstest.MapFS{
".ndignore": &fstest.MapFile{Data: []byte("*.tmp\n")},
"parent": &fstest.MapFile{Mode: fs.ModeDir},
"parent/.ndignore": &fstest.MapFile{Data: []byte("test\n")},
"parent/test": &fstest.MapFile{Mode: fs.ModeDir},
"parent/test/file.mp3": &fstest.MapFile{Data: []byte("audio")},
"parent/prod": &fstest.MapFile{Mode: fs.ModeDir},
"parent/prod/cache.tmp": &fstest.MapFile{Data: []byte("tmp")},
}
// parent/test should be ignored by parent/.ndignore
result := shouldIgnorePath(ctx, complexFS, "parent/test")
Expect(result).To(BeTrue())
// parent/prod/cache.tmp path should be ignored by root .ndignore (*.tmp)
result = shouldIgnorePath(ctx, complexFS, "parent/prod/cache.tmp")
Expect(result).To(BeTrue())
// parent/prod directory itself should NOT be ignored
result = shouldIgnorePath(ctx, complexFS, "parent/prod")
Expect(result).To(BeFalse())
})
})
var _ = Describe("resolveFolderPath", func() {
var mockFS fs.FS