diff --git a/scanner/watcher.go b/scanner/watcher.go index e8a0bae13..e377f625a 100644 --- a/scanner/watcher.go +++ b/scanner/watcher.go @@ -73,7 +73,6 @@ func (w *watcher) Run(ctx context.Context) error { // Main scan triggering loop trigger := time.NewTimer(w.triggerWait) trigger.Stop() - waiting := false targets := make(map[ScanTarget]struct{}) for { select { @@ -89,7 +88,6 @@ func (w *watcher) Run(ctx context.Context) error { trigger.Reset(w.triggerWait * 3) continue } - waiting = false // Convert targets map to slice targetSlice := make([]ScanTarget, 0, len(targets)) @@ -122,15 +120,15 @@ func (w *watcher) Run(ctx context.Context) error { lib := notification.Library folderPath := notification.FolderPath - // Add target to the map (deduplicates automatically) - targets[ScanTarget{LibraryID: lib.ID, FolderPath: folderPath}] = struct{}{} - - if !waiting { - log.Debug(ctx, "Watcher: Detected changes. Waiting for more changes before triggering scan", - "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, "folderPath", folderPath) - waiting = true + // If already scheduled for scan, skip + if _, exists := targets[ScanTarget{LibraryID: lib.ID, FolderPath: folderPath}]; exists { + continue } + targets[ScanTarget{LibraryID: lib.ID, FolderPath: folderPath}] = struct{}{} trigger.Reset(w.triggerWait) + + log.Debug(ctx, "Watcher: Detected changes. Waiting for more changes before triggering scan", + "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, "folderPath", folderPath) } } } @@ -241,27 +239,7 @@ func (w *watcher) watchLibrary(ctx context.Context, lib *model.Library) error { log.Trace(ctx, "Detected change", "libraryID", lib.ID, "path", path, "absoluteLibPath", absLibPath) // Find the folder to scan - validate path exists as directory, walk up if needed - folderPath := path - for { - info, err := fs.Stat(fsys, folderPath) - if err == nil && info.IsDir() { - // Found a valid directory - break - } - if folderPath == "." || folderPath == "" { - // Reached root, scan entire library - folderPath = "" - break - } - // Walk up the tree - dir, _ := filepath.Split(folderPath) - if dir == "" || dir == "." { - folderPath = "" - break - } - // Remove trailing slash - folderPath = filepath.Clean(dir) - } + folderPath := resolveFolderPath(fsys, path) // Notify the main watcher of changes select { @@ -273,6 +251,36 @@ func (w *watcher) watchLibrary(ctx context.Context, lib *model.Library) error { } } +// 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 { + // Handle root paths immediately + if path == "." || path == "" { + return "" + } + + folderPath := path + for { + info, err := fs.Stat(fsys, folderPath) + if err == nil && info.IsDir() { + // Found a valid directory + return folderPath + } + if folderPath == "." || folderPath == "" { + // Reached root, scan entire library + return "" + } + // Walk up the tree + dir, _ := filepath.Split(folderPath) + if dir == "" || dir == "." { + return "" + } + // Remove trailing slash + folderPath = filepath.Clean(dir) + } +} + func isIgnoredPath(_ context.Context, _ fs.FS, path string) bool { baseDir, name := filepath.Split(path) switch { diff --git a/scanner/watcher_test.go b/scanner/watcher_test.go index d487a9c10..6341797f6 100644 --- a/scanner/watcher_test.go +++ b/scanner/watcher_test.go @@ -2,7 +2,9 @@ package scanner import ( "context" + "io/fs" "sync" + "testing/fstest" "time" "github.com/navidrome/navidrome/conf" @@ -277,6 +279,64 @@ var _ = Describe("Watcher", func() { }) }) +var _ = Describe("resolveFolderPath", func() { + var mockFS fs.FS + + BeforeEach(func() { + // Create a mock filesystem with some directories and files + mockFS = fstest.MapFS{ + "artist1": &fstest.MapFile{Mode: fs.ModeDir}, + "artist1/album1": &fstest.MapFile{Mode: fs.ModeDir}, + "artist1/album1/track1.mp3": &fstest.MapFile{Data: []byte("audio")}, + "artist1/album1/track2.mp3": &fstest.MapFile{Data: []byte("audio")}, + "artist1/album2": &fstest.MapFile{Mode: fs.ModeDir}, + "artist1/album2/song.flac": &fstest.MapFile{Data: []byte("audio")}, + "artist2": &fstest.MapFile{Mode: fs.ModeDir}, + "artist2/cover.jpg": &fstest.MapFile{Data: []byte("image")}, + } + }) + + It("returns directory path when given a directory", func() { + result := resolveFolderPath(mockFS, "artist1/album1") + Expect(result).To(Equal("artist1/album1")) + }) + + It("walks up to parent directory when given a file path", func() { + result := resolveFolderPath(mockFS, "artist1/album1/track1.mp3") + Expect(result).To(Equal("artist1/album1")) + }) + + It("walks up multiple levels if needed", func() { + result := resolveFolderPath(mockFS, "artist1/album1/nonexistent/file.mp3") + Expect(result).To(Equal("artist1/album1")) + }) + + It("returns empty string for non-existent paths at root", func() { + result := resolveFolderPath(mockFS, "nonexistent/path/file.mp3") + Expect(result).To(Equal("")) + }) + + It("returns empty string for dot path", func() { + result := resolveFolderPath(mockFS, ".") + Expect(result).To(Equal("")) + }) + + It("returns empty string for empty path", func() { + result := resolveFolderPath(mockFS, "") + Expect(result).To(Equal("")) + }) + + It("handles nested file paths correctly", func() { + result := resolveFolderPath(mockFS, "artist1/album2/song.flac") + Expect(result).To(Equal("artist1/album2")) + }) + + It("resolves to top-level directory", func() { + result := resolveFolderPath(mockFS, "artist2/cover.jpg") + Expect(result).To(Equal("artist2")) + }) +}) + // MockScanner implements scanner.Scanner for testing type MockScanner struct { mu sync.Mutex