refactor(watcher): add resolveFolderPath function for testability

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2025-11-10 21:23:28 -05:00
parent 1bd58a14ab
commit b361837f50
2 changed files with 98 additions and 30 deletions

View File

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

View File

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