diff --git a/scanner/walk_dir_tree.go b/scanner/walk_dir_tree.go index 6afe76755..d374a4aca 100644 --- a/scanner/walk_dir_tree.go +++ b/scanner/walk_dir_tree.go @@ -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) diff --git a/scanner/watcher.go b/scanner/watcher.go index e377f625a..43827df22 100644 --- a/scanner/watcher.go +++ b/scanner/watcher.go @@ -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) +} diff --git a/scanner/watcher_test.go b/scanner/watcher_test.go index 6341797f6..c0fb24dee 100644 --- a/scanner/watcher_test.go +++ b/scanner/watcher_test.go @@ -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