navidrome/scanner/watcher_test.go
Junker der Provinz ac6ec80b2b fix(scanner): only skip lost+found at the library root
lost+found was in the blocklist of directories skipped anywhere in the tree,
but the filesystem only ever creates it at the root of a mount point, which
for a music library is the library root. Deeper in the tree the name belongs
to whoever created the folder, and it happens to be a real album title
(Jonathan Coulton's "lost+found"), so such folders were silently dropped
from the scan.

Split the blocklist in two: ignoredDirs keeps matching at any depth, and the
new rootOnlyIgnoredDirs matches only for a direct child of the library root.
isIgnoredEntry/isDirIgnored now take the library-relative path instead of a
bare name, and the watcher rebuilds each parent component into its full path
before applying the same policy, so watching and walking stay in sync.

Fixes #5592

Signed-off-by: Junker der Provinz <133605895+junkerderprovinz@users.noreply.github.com>
2026-10-02 03:12:48 +02:00

547 lines
18 KiB
Go

package scanner
import (
"context"
"io/fs"
"path/filepath"
"testing/fstest"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/storage/storagetest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Watcher", func() {
var ctx context.Context
var cancel context.CancelFunc
var mockScanner *tests.MockScanner
var mockDS *tests.MockDataStore
var w *watcher
var lib *model.Library
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
// Must dwarf the 20ms Consistently windows below, or a loaded runner's delayed timer reset flakes the debouncing spec.
conf.Server.Scanner.WatcherWait = 200 * time.Millisecond
ctx, cancel = context.WithCancel(GinkgoT().Context())
DeferCleanup(cancel)
// Use a fake storage scheme: watchLibrary goroutines spawned by Run/Watch are not
// joined on spec teardown, and the real file:// storage reads conf.Server on
// construction, racing with the configtest cleanup that restores the config.
storagetest.Register("fake-watcher", &storagetest.FakeFS{})
lib = &model.Library{
ID: 1,
Name: "Test Library",
Path: "fake-watcher:///test/library",
}
// Set up mocks
mockScanner = tests.NewMockScanner()
mockDS = &tests.MockDataStore{}
mockLibRepo := &tests.MockLibraryRepo{}
mockLibRepo.SetData(model.Libraries{*lib})
mockDS.MockedLibrary = mockLibRepo
// Create a new watcher instance (not singleton) for testing
w = &watcher{
ds: mockDS,
scanner: mockScanner,
triggerWait: conf.Server.Scanner.WatcherWait,
watcherNotify: make(chan scanNotification, 10),
libraryWatchers: make(map[int]*libraryWatcherInstance),
mainCtx: ctx,
}
})
Describe("Watch before Run", func() {
It("returns nil and does not panic when mainCtx is nil", func() {
w.mainCtx = nil
err := w.Watch(ctx, lib)
Expect(err).ToNot(HaveOccurred())
Expect(w.libraryWatchers).To(BeEmpty())
})
})
Describe("Target Collection and Deduplication", func() {
BeforeEach(func() {
// Start watcher in background
go func() {
_ = w.Run(ctx)
}()
// Give watcher time to initialize
time.Sleep(10 * time.Millisecond)
})
It("creates separate targets for different folders", FlakeAttempts(3), func() {
// Send notifications for different folders
w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"}
w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist2"}
// Wait for a scan that collected both targets
Eventually(func() []model.ScanTarget {
calls := mockScanner.GetScanFoldersCalls()
if len(calls) == 0 {
return nil
}
return calls[0].Targets
}, 2*time.Second, 10*time.Millisecond).Should(HaveLen(2))
// Verify targets
calls := mockScanner.GetScanFoldersCalls()
folderPaths := make(map[string]bool)
for _, target := range calls[0].Targets {
Expect(target.LibraryID).To(Equal(1))
folderPaths[target.FolderPath] = true
}
Expect(folderPaths).To(HaveKey("artist1"))
Expect(folderPaths).To(HaveKey("artist2"))
})
It("handles different folder paths correctly", func() {
// Send notification for nested folder
w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1/album1"}
// Wait for watcher to process and trigger scan
Eventually(func() int {
return mockScanner.GetScanFoldersCallCount()
}, 2*time.Second, 10*time.Millisecond).Should(Equal(1))
// Verify the target
calls := mockScanner.GetScanFoldersCalls()
Expect(calls).To(HaveLen(1))
Expect(calls[0].Targets).To(HaveLen(1))
Expect(calls[0].Targets[0].FolderPath).To(Equal("artist1/album1"))
})
It("deduplicates folder and file within same folder", func() {
// Send multiple notifications for the same folder
w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1/album1"}
w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1/album1"}
w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1/album1"}
// Wait for watcher to process and trigger scan
Eventually(func() int {
return mockScanner.GetScanFoldersCallCount()
}, 2*time.Second, 10*time.Millisecond).Should(Equal(1))
// Verify only one target despite multiple file/folder changes
calls := mockScanner.GetScanFoldersCalls()
Expect(calls).To(HaveLen(1))
Expect(calls[0].Targets).To(HaveLen(1))
Expect(calls[0].Targets[0].FolderPath).To(Equal("artist1/album1"))
})
})
Describe("Timer Behavior", func() {
BeforeEach(func() {
// Start watcher in background
go func() {
_ = w.Run(ctx)
}()
// Give watcher time to initialize
time.Sleep(10 * time.Millisecond)
})
It("resets timer on each change (debouncing)", FlakeAttempts(3), func() {
// Send first notification
w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"}
// Verify no scan fires during a window shorter than the debounce wait
Consistently(func() int {
return mockScanner.GetScanFoldersCallCount()
}, 20*time.Millisecond, 5*time.Millisecond).Should(Equal(0))
// Send another notification (resets timer)
w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"}
// Again, no scan should fire within a short window
Consistently(func() int {
return mockScanner.GetScanFoldersCallCount()
}, 20*time.Millisecond, 5*time.Millisecond).Should(Equal(0))
// Now wait for the debounce timer to expire and trigger scan
Eventually(func() int {
return mockScanner.GetScanFoldersCallCount()
}, 2*time.Second, 10*time.Millisecond).Should(Equal(1))
})
It("triggers scan after quiet period", func() {
// Send notification
w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"}
// No scan immediately
Expect(mockScanner.GetScanFoldersCallCount()).To(Equal(0))
// Wait for quiet period
Eventually(func() int {
return mockScanner.GetScanFoldersCallCount()
}, 2*time.Second, 10*time.Millisecond).Should(Equal(1))
})
})
Describe("Empty and Root Paths", func() {
BeforeEach(func() {
// Start watcher in background
go func() {
_ = w.Run(ctx)
}()
// Give watcher time to initialize
time.Sleep(10 * time.Millisecond)
})
It("handles empty folder path (library root)", func() {
// Send notification with empty folder path
w.watcherNotify <- scanNotification{Library: lib, FolderPath: ""}
// Wait for scan
Eventually(func() int {
return mockScanner.GetScanFoldersCallCount()
}, 2*time.Second, 10*time.Millisecond).Should(Equal(1))
// Should scan the library root
calls := mockScanner.GetScanFoldersCalls()
Expect(calls).To(HaveLen(1))
Expect(calls[0].Targets).To(HaveLen(1))
Expect(calls[0].Targets[0].FolderPath).To(Equal(""))
})
It("deduplicates empty and dot paths", func() {
// Send notifications with empty and dot paths
w.watcherNotify <- scanNotification{Library: lib, FolderPath: ""}
w.watcherNotify <- scanNotification{Library: lib, FolderPath: ""}
// Wait for scan
Eventually(func() int {
return mockScanner.GetScanFoldersCallCount()
}, 2*time.Second, 10*time.Millisecond).Should(Equal(1))
// Should have only one target
calls := mockScanner.GetScanFoldersCalls()
Expect(calls).To(HaveLen(1))
Expect(calls[0].Targets).To(HaveLen(1))
})
})
Describe("Multiple Libraries", func() {
var lib2 *model.Library
BeforeEach(func() {
// Create second library
lib2 = &model.Library{
ID: 2,
Name: "Test Library 2",
Path: "fake-watcher:///test/library2",
}
mockLibRepo := mockDS.MockedLibrary.(*tests.MockLibraryRepo)
mockLibRepo.SetData(model.Libraries{*lib, *lib2})
// Start watcher in background
go func() {
_ = w.Run(ctx)
}()
// Give watcher time to initialize
time.Sleep(10 * time.Millisecond)
})
It("creates separate targets for different libraries", func() {
// Send notifications for both libraries
w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"}
w.watcherNotify <- scanNotification{Library: lib2, FolderPath: "artist2"}
// Wait for a scan that collected both targets
Eventually(func() []model.ScanTarget {
calls := mockScanner.GetScanFoldersCalls()
if len(calls) == 0 {
return nil
}
return calls[0].Targets
}, 2*time.Second, 10*time.Millisecond).Should(HaveLen(2))
// Verify library IDs are different
calls := mockScanner.GetScanFoldersCalls()
libraryIDs := make(map[int]bool)
for _, target := range calls[0].Targets {
libraryIDs[target.LibraryID] = true
}
Expect(libraryIDs).To(HaveKey(1))
Expect(libraryIDs).To(HaveKey(2))
})
})
Describe(".ndignore handling", func() {
var ctx context.Context
var cancel context.CancelFunc
var w *watcher
var mockFS *mockMusicFS
var lib *model.Library
var eventChan chan string
var absLibPath string
BeforeEach(func() {
ctx, cancel = context.WithCancel(GinkgoT().Context())
DeferCleanup(cancel)
// Set up library
var err error
absLibPath, err = filepath.Abs(".")
Expect(err).NotTo(HaveOccurred())
lib = &model.Library{
ID: 1,
Name: "Test Library",
Path: absLibPath,
}
// Create watcher with notification channel
w = &watcher{
watcherNotify: make(chan scanNotification, 10),
}
eventChan = make(chan string, 10)
})
// Helper to send an event - converts relative path to absolute
sendEvent := func(relativePath string) {
path := filepath.Join(absLibPath, relativePath)
eventChan <- path
}
// Helper to start the real event processing loop
startEventProcessing := func() {
go func() {
defer GinkgoRecover()
// Call the actual processLibraryEvents method - testing the real implementation!
_ = w.processLibraryEvents(ctx, lib, mockFS, eventChan, absLibPath)
}()
}
Context("when a folder matching .ndignore is deleted", func() {
BeforeEach(func() {
// Create filesystem with .ndignore containing _TEMP pattern
// The deleted folder (_TEMP) will NOT exist in the filesystem
mockFS = &mockMusicFS{
FS: fstest.MapFS{
"rock": &fstest.MapFile{Mode: fs.ModeDir},
"rock/.ndignore": &fstest.MapFile{Data: []byte("_TEMP\n")},
"rock/valid_album": &fstest.MapFile{Mode: fs.ModeDir},
"rock/valid_album/track.mp3": &fstest.MapFile{Data: []byte("audio")},
},
}
})
It("should NOT send scan notification when deleted folder matches .ndignore", func() {
startEventProcessing()
// Simulate deletion event for rock/_TEMP
sendEvent("rock/_TEMP")
// Wait a bit to ensure event is processed
time.Sleep(50 * time.Millisecond)
// No notification should have been sent
Consistently(eventChan, 100*time.Millisecond).Should(BeEmpty())
})
It("should send scan notification for valid folder deletion", func() {
startEventProcessing()
// Simulate deletion event for rock/other_folder (not in .ndignore and doesn't exist)
// Since it doesn't exist in mockFS, resolveFolderPath will walk up to "rock"
sendEvent("rock/other_folder")
// Should receive notification for parent folder
Eventually(w.watcherNotify, 200*time.Millisecond).Should(Receive(Equal(scanNotification{
Library: lib,
FolderPath: "rock",
})))
})
})
Context("with nested folder patterns", func() {
BeforeEach(func() {
mockFS = &mockMusicFS{
FS: fstest.MapFS{
"music": &fstest.MapFile{Mode: fs.ModeDir},
"music/.ndignore": &fstest.MapFile{Data: []byte("**/temp\n**/cache\n")},
"music/rock": &fstest.MapFile{Mode: fs.ModeDir},
"music/rock/artist": &fstest.MapFile{Mode: fs.ModeDir},
},
}
})
It("should NOT send notification when nested ignored folder is deleted", func() {
tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)")
startEventProcessing()
// Simulate deletion of music/rock/artist/temp (matches **/temp)
sendEvent("music/rock/artist/temp")
// Wait to ensure event is processed
time.Sleep(50 * time.Millisecond)
// No notification should be sent
Expect(w.watcherNotify).To(BeEmpty(), "Expected no scan notification for nested ignored folder")
})
It("should send notification for non-ignored nested folder", func() {
tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)")
startEventProcessing()
// Simulate change in music/rock/artist (doesn't match any pattern)
sendEvent("music/rock/artist")
// Should receive notification
Eventually(w.watcherNotify, 200*time.Millisecond).Should(Receive(Equal(scanNotification{
Library: lib,
FolderPath: "music/rock/artist",
})))
})
})
Context("with file events in ignored folders", func() {
BeforeEach(func() {
mockFS = &mockMusicFS{
FS: fstest.MapFS{
"rock": &fstest.MapFile{Mode: fs.ModeDir},
"rock/.ndignore": &fstest.MapFile{Data: []byte("_TEMP\n")},
},
}
})
It("should NOT send notification for file changes in ignored folders", func() {
tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)")
startEventProcessing()
// Simulate file change in rock/_TEMP/file.mp3
sendEvent("rock/_TEMP/file.mp3")
// Wait to ensure event is processed
time.Sleep(50 * time.Millisecond)
// No notification should be sent
Expect(w.watcherNotify).To(BeEmpty(), "Expected no scan notification for file in ignored folder")
})
})
})
})
var _ = Describe("isIgnoredPath", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
Context("with IgnoreDotFolders enabled (default)", func() {
BeforeEach(func() {
conf.Server.Scanner.IgnoreDotFolders = true
})
DescribeTable("returns expected result",
func(p string, expected bool) {
Expect(isIgnoredPath(context.Background(), nil, filepath.FromSlash(p))).To(Equal(expected))
},
Entry("media file in normal folder", "rock/Album/track.mp3", false),
Entry("dot-prefixed media file", "rock/Album/.hidden.mp3", true),
Entry("media file inside a dot-folder", "rock/.Hidden Album/track.mp3", true),
Entry("media file inside a blocklisted folder", "rock/.streams/stream.mp3", true),
Entry("media file inside .git", "rock/.git/track.mp3", true),
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),
)
})
Context("with IgnoreDotFolders disabled", func() {
BeforeEach(func() {
conf.Server.Scanner.IgnoreDotFolders = false
})
DescribeTable("returns expected result",
func(p string, expected bool) {
Expect(isIgnoredPath(context.Background(), nil, filepath.FromSlash(p))).To(Equal(expected))
},
Entry("media file inside a dot-folder is allowed", "rock/.Hidden Album/track.mp3", false),
Entry("dot-prefixed media file is still ignored", "rock/Album/.hidden.mp3", true),
Entry("dot-folder itself is allowed", "rock/.Hidden Album", false),
Entry("blocklisted folder still ignored", "rock/.streams/stream.mp3", true),
Entry(".git still ignored", "rock/.git/config", true),
)
})
})
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() {
tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)")
result := resolveFolderPath(mockFS, "artist1/album1/track1.mp3")
Expect(result).To(Equal("artist1/album1"))
})
It("walks up multiple levels if needed", func() {
tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)")
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() {
tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)")
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"))
})
})