Files
navidrome/scanner/folder_entry_test.go
Deluan Quintão 28d5299ffc feat(scanner): implement selective folder scanning and file system watcher improvements (#4674)
* feat: Add selective folder scanning capability

Implement targeted scanning of specific library/folder pairs without
full recursion. This enables efficient rescanning of individual folders
when changes are detected, significantly reducing scan time for large
libraries.

Key changes:
- Add ScanTarget struct and ScanFolders API to Scanner interface
- Implement CLI flag --targets for specifying libraryID:folderPath pairs
- Add FolderRepository.GetByPaths() for batch folder info retrieval
- Create loadSpecificFolders() for non-recursive directory loading
- Scope GC operations to affected libraries only (with TODO for full impl)
- Add comprehensive tests for selective scanning behavior

The selective scan:
- Only processes specified folders (no subdirectory recursion)
- Maintains library isolation
- Runs full maintenance pipeline scoped to affected libraries
- Supports both full and quick scan modes

Examples:
  navidrome scan --targets "1:Music/Rock,1:Music/Jazz"
  navidrome scan --full --targets "2:Classical"

* feat(folder): replace GetByPaths with GetFolderUpdateInfo for improved folder updates retrieval

Signed-off-by: Deluan <deluan@navidrome.org>

* test: update parseTargets test to handle folder names with spaces

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(folder): remove unused LibraryPath struct and update GC logging message

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(folder): enhance external scanner to support target-specific scanning

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(scanner): simplify scanner methods

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(watcher): implement folder scanning notifications with deduplication

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(watcher): add resolveFolderPath function for testability

Signed-off-by: Deluan <deluan@navidrome.org>

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

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(scanner): implement IgnoreChecker for managing .ndignore patterns

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(ignore_checker): rename scanner to lineScanner for clarity

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(scanner): enhance ScanTarget struct with String method for better target representation

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(scanner): validate library ID to prevent negative values

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(scanner): simplify GC method by removing library ID parameter

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(scanner): update folder scanning to include all descendants of specified folders

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): allow selective scan in the /startScan endpoint

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(scanner): update CallScan to handle specific library/folder pairs

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(scanner): streamline scanning logic by removing scanAll method

Signed-off-by: Deluan <deluan@navidrome.org>

* test: enhance mockScanner for thread safety and improve test reliability

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(scanner): move scanner.ScanTarget to model.ScanTarget

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: move scanner types to model,implement MockScanner

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(scanner): update scanner interface and implementations to use model.Scanner

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(folder_repository): normalize target path handling by using filepath.Clean

Signed-off-by: Deluan <deluan@navidrome.org>

* test(folder_repository): add comprehensive tests for folder retrieval and child exclusion

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(scanner): simplify selective scan logic using slice.Filter

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(scanner): streamline phase folder and album creation by removing unnecessary library parameter

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(scanner): move initialization logic from phase_1 to the scanner itself

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(tests): rename selective scan test file to scanner_selective_test.go

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(configuration): add DevSelectiveWatcher configuration option

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(watcher): enhance .ndignore handling for folder deletions and file changes

Signed-off-by: Deluan <deluan@navidrome.org>

* docs(scanner): comments

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(scanner): enhance walkDirTree to support target folder scanning

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(scanner, watcher): handle errors when pushing ignore patterns for folders

Signed-off-by: Deluan <deluan@navidrome.org>

* Update scanner/phase_1_folders.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* refactor(scanner): replace parseTargets function with direct call to scanner.ParseTargets

Signed-off-by: Deluan <deluan@navidrome.org>

* test(scanner): add tests for ScanBegin and ScanEnd functionality

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(library): update PRAGMA optimize to check table sizes without ANALYZE

Signed-off-by: Deluan <deluan@navidrome.org>

* test(scanner): refactor tests

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(ui): add selective scan options and update translations

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(ui): add quick and full scan options for individual libraries

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(ui): add Scan buttonsto the LibraryList

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(scan): update scanning parameters from 'path' to 'target' for selective scans.

* refactor(scan): move ParseTargets function to model package

* test(scan): suppress unused return value from SetUserLibraries in tests

* feat(gc): enhance garbage collection to support selective library purging

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(scanner): prevent race condition when scanning deleted folders

When the watcher detects changes in a folder that gets deleted before
the scanner runs (due to the 10-second delay), the scanner was
prematurely removing these folders from the tracking map, preventing
them from being marked as missing.

The issue occurred because `newFolderEntry` was calling `popLastUpdate`
before verifying the folder actually exists on the filesystem.

Changes:
- Move fs.Stat check before newFolderEntry creation in loadDir to
  ensure deleted folders remain in lastUpdates for finalize() to handle
- Add early existence check in walkDirTree to skip non-existent target
  folders with a warning log
- Add unit test verifying non-existent folders aren't removed from
  lastUpdates prematurely
- Add integration test for deleted folder scenario with ScanFolders

Fixes the issue where deleting entire folders (e.g., /music/AC_DC)
wouldn't mark tracks as missing when using selective folder scanning.

* refactor(scan): streamline folder entry creation and update handling

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(scan): add '@Recycle' (QNAP) to ignored directories list

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(log): improve thread safety in logging level management

* test(scan): move unit tests for ParseTargets function

Signed-off-by: Deluan <deluan@navidrome.org>

* review

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: deluan <deluan.quintao@mechanical-orchard.com>
2025-11-14 22:15:43 -05:00

544 lines
15 KiB
Go

package scanner
import (
"io/fs"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("folder_entry", func() {
var (
lib model.Library
job *scanJob
path string
)
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
lib = model.Library{
ID: 500,
Path: "/music",
LastScanStartedAt: time.Now().Add(-1 * time.Hour),
FullScanInProgress: false,
}
job = &scanJob{
lib: lib,
lastUpdates: make(map[string]model.FolderUpdateInfo),
}
path = "test/folder"
})
Describe("newFolderEntry", func() {
It("creates a new folder entry with correct initialization", func() {
folderID := model.FolderID(lib, path)
updateInfo := model.FolderUpdateInfo{
UpdatedAt: time.Now().Add(-30 * time.Minute),
Hash: "previous-hash",
}
entry := newFolderEntry(job, folderID, path, updateInfo.UpdatedAt, updateInfo.Hash)
Expect(entry.id).To(Equal(folderID))
Expect(entry.job).To(Equal(job))
Expect(entry.path).To(Equal(path))
Expect(entry.audioFiles).To(BeEmpty())
Expect(entry.imageFiles).To(BeEmpty())
Expect(entry.albumIDMap).To(BeEmpty())
Expect(entry.updTime).To(Equal(updateInfo.UpdatedAt))
Expect(entry.prevHash).To(Equal(updateInfo.Hash))
})
})
Describe("createFolderEntry", func() {
It("removes the lastUpdate from the job after creation", func() {
folderID := model.FolderID(lib, path)
updateInfo := model.FolderUpdateInfo{
UpdatedAt: time.Now().Add(-30 * time.Minute),
Hash: "previous-hash",
}
job.lastUpdates[folderID] = updateInfo
entry := job.createFolderEntry(path)
Expect(entry.updTime).To(Equal(updateInfo.UpdatedAt))
Expect(entry.prevHash).To(Equal(updateInfo.Hash))
Expect(job.lastUpdates).ToNot(HaveKey(folderID))
})
})
Describe("folderEntry", func() {
var entry *folderEntry
BeforeEach(func() {
folderID := model.FolderID(lib, path)
entry = newFolderEntry(job, folderID, path, time.Time{}, "")
})
Describe("hasNoFiles", func() {
It("returns true when folder has no files or subfolders", func() {
Expect(entry.hasNoFiles()).To(BeTrue())
})
It("returns false when folder has audio files", func() {
entry.audioFiles["test.mp3"] = &fakeDirEntry{name: "test.mp3"}
Expect(entry.hasNoFiles()).To(BeFalse())
})
It("returns false when folder has image files", func() {
entry.imageFiles["cover.jpg"] = &fakeDirEntry{name: "cover.jpg"}
Expect(entry.hasNoFiles()).To(BeFalse())
})
It("returns false when folder has playlists", func() {
entry.numPlaylists = 1
Expect(entry.hasNoFiles()).To(BeFalse())
})
It("ignores subfolders when checking for no files", func() {
entry.numSubFolders = 1
Expect(entry.hasNoFiles()).To(BeTrue())
})
It("returns false when folder has multiple types of content", func() {
entry.audioFiles["test.mp3"] = &fakeDirEntry{name: "test.mp3"}
entry.imageFiles["cover.jpg"] = &fakeDirEntry{name: "cover.jpg"}
entry.numPlaylists = 2
entry.numSubFolders = 3
Expect(entry.hasNoFiles()).To(BeFalse())
})
})
Describe("isEmpty", func() {
It("returns true when folder has no files or subfolders", func() {
Expect(entry.isEmpty()).To(BeTrue())
})
It("returns false when folder has audio files", func() {
entry.audioFiles["test.mp3"] = &fakeDirEntry{name: "test.mp3"}
Expect(entry.isEmpty()).To(BeFalse())
})
It("returns false when folder has subfolders", func() {
entry.numSubFolders = 1
Expect(entry.isEmpty()).To(BeFalse())
})
})
Describe("isNew", func() {
It("returns true when updTime is zero", func() {
entry.updTime = time.Time{}
Expect(entry.isNew()).To(BeTrue())
})
It("returns false when updTime is not zero", func() {
entry.updTime = time.Now()
Expect(entry.isNew()).To(BeFalse())
})
})
Describe("toFolder", func() {
BeforeEach(func() {
entry.audioFiles = map[string]fs.DirEntry{
"song1.mp3": &fakeDirEntry{name: "song1.mp3"},
"song2.mp3": &fakeDirEntry{name: "song2.mp3"},
}
entry.imageFiles = map[string]fs.DirEntry{
"cover.jpg": &fakeDirEntry{name: "cover.jpg"},
"folder.png": &fakeDirEntry{name: "folder.png"},
}
entry.numPlaylists = 3
entry.imagesUpdatedAt = time.Now()
})
It("converts folder entry to model.Folder correctly", func() {
folder := entry.toFolder()
Expect(folder.LibraryID).To(Equal(lib.ID))
Expect(folder.ID).To(Equal(entry.id))
Expect(folder.NumAudioFiles).To(Equal(2))
Expect(folder.ImageFiles).To(ConsistOf("cover.jpg", "folder.png"))
Expect(folder.ImagesUpdatedAt).To(Equal(entry.imagesUpdatedAt))
Expect(folder.Hash).To(Equal(entry.hash()))
})
It("sets NumPlaylists when folder is in playlists path", func() {
// Mock InPlaylistsPath to return true by setting empty PlaylistsPath
originalPath := conf.Server.PlaylistsPath
conf.Server.PlaylistsPath = ""
DeferCleanup(func() { conf.Server.PlaylistsPath = originalPath })
folder := entry.toFolder()
Expect(folder.NumPlaylists).To(Equal(3))
})
It("does not set NumPlaylists when folder is not in playlists path", func() {
// Mock InPlaylistsPath to return false by setting a different path
originalPath := conf.Server.PlaylistsPath
conf.Server.PlaylistsPath = "different/path"
DeferCleanup(func() { conf.Server.PlaylistsPath = originalPath })
folder := entry.toFolder()
Expect(folder.NumPlaylists).To(BeZero())
})
})
Describe("hash", func() {
BeforeEach(func() {
entry.modTime = time.Date(2023, 1, 15, 12, 0, 0, 0, time.UTC)
entry.imagesUpdatedAt = time.Date(2023, 1, 16, 14, 30, 0, 0, time.UTC)
})
It("produces deterministic hash for same content", func() {
entry.audioFiles = map[string]fs.DirEntry{
"b.mp3": &fakeDirEntry{name: "b.mp3"},
"a.mp3": &fakeDirEntry{name: "a.mp3"},
}
entry.imageFiles = map[string]fs.DirEntry{
"z.jpg": &fakeDirEntry{name: "z.jpg"},
"x.png": &fakeDirEntry{name: "x.png"},
}
entry.numPlaylists = 2
entry.numSubFolders = 3
hash1 := entry.hash()
// Reverse order of maps
entry.audioFiles = map[string]fs.DirEntry{
"a.mp3": &fakeDirEntry{name: "a.mp3"},
"b.mp3": &fakeDirEntry{name: "b.mp3"},
}
entry.imageFiles = map[string]fs.DirEntry{
"x.png": &fakeDirEntry{name: "x.png"},
"z.jpg": &fakeDirEntry{name: "z.jpg"},
}
hash2 := entry.hash()
Expect(hash1).To(Equal(hash2))
})
It("produces different hash when audio files change", func() {
entry.audioFiles = map[string]fs.DirEntry{
"song1.mp3": &fakeDirEntry{name: "song1.mp3"},
}
hash1 := entry.hash()
entry.audioFiles["song2.mp3"] = &fakeDirEntry{name: "song2.mp3"}
hash2 := entry.hash()
Expect(hash1).ToNot(Equal(hash2))
})
It("produces different hash when image files change", func() {
entry.imageFiles = map[string]fs.DirEntry{
"cover.jpg": &fakeDirEntry{name: "cover.jpg"},
}
hash1 := entry.hash()
entry.imageFiles["folder.png"] = &fakeDirEntry{name: "folder.png"}
hash2 := entry.hash()
Expect(hash1).ToNot(Equal(hash2))
})
It("produces different hash when modification time changes", func() {
hash1 := entry.hash()
entry.modTime = entry.modTime.Add(1 * time.Hour)
hash2 := entry.hash()
Expect(hash1).ToNot(Equal(hash2))
})
It("produces different hash when playlist count changes", func() {
hash1 := entry.hash()
entry.numPlaylists = 5
hash2 := entry.hash()
Expect(hash1).ToNot(Equal(hash2))
})
It("produces different hash when subfolder count changes", func() {
hash1 := entry.hash()
entry.numSubFolders = 3
hash2 := entry.hash()
Expect(hash1).ToNot(Equal(hash2))
})
It("produces different hash when images updated time changes", func() {
hash1 := entry.hash()
entry.imagesUpdatedAt = entry.imagesUpdatedAt.Add(2 * time.Hour)
hash2 := entry.hash()
Expect(hash1).ToNot(Equal(hash2))
})
It("produces different hash when audio file size changes", func() {
entry.audioFiles["test.mp3"] = &fakeDirEntry{
name: "test.mp3",
fileInfo: &fakeFileInfo{
name: "test.mp3",
size: 1000,
modTime: time.Now(),
},
}
hash1 := entry.hash()
entry.audioFiles["test.mp3"] = &fakeDirEntry{
name: "test.mp3",
fileInfo: &fakeFileInfo{
name: "test.mp3",
size: 2000, // Different size
modTime: time.Now(),
},
}
hash2 := entry.hash()
Expect(hash1).ToNot(Equal(hash2))
})
It("produces different hash when audio file modification time changes", func() {
baseTime := time.Now()
entry.audioFiles["test.mp3"] = &fakeDirEntry{
name: "test.mp3",
fileInfo: &fakeFileInfo{
name: "test.mp3",
size: 1000,
modTime: baseTime,
},
}
hash1 := entry.hash()
entry.audioFiles["test.mp3"] = &fakeDirEntry{
name: "test.mp3",
fileInfo: &fakeFileInfo{
name: "test.mp3",
size: 1000,
modTime: baseTime.Add(1 * time.Hour), // Different modtime
},
}
hash2 := entry.hash()
Expect(hash1).ToNot(Equal(hash2))
})
It("produces different hash when image file size changes", func() {
entry.imageFiles["cover.jpg"] = &fakeDirEntry{
name: "cover.jpg",
fileInfo: &fakeFileInfo{
name: "cover.jpg",
size: 5000,
modTime: time.Now(),
},
}
hash1 := entry.hash()
entry.imageFiles["cover.jpg"] = &fakeDirEntry{
name: "cover.jpg",
fileInfo: &fakeFileInfo{
name: "cover.jpg",
size: 6000, // Different size
modTime: time.Now(),
},
}
hash2 := entry.hash()
Expect(hash1).ToNot(Equal(hash2))
})
It("produces different hash when image file modification time changes", func() {
baseTime := time.Now()
entry.imageFiles["cover.jpg"] = &fakeDirEntry{
name: "cover.jpg",
fileInfo: &fakeFileInfo{
name: "cover.jpg",
size: 5000,
modTime: baseTime,
},
}
hash1 := entry.hash()
entry.imageFiles["cover.jpg"] = &fakeDirEntry{
name: "cover.jpg",
fileInfo: &fakeFileInfo{
name: "cover.jpg",
size: 5000,
modTime: baseTime.Add(1 * time.Hour), // Different modtime
},
}
hash2 := entry.hash()
Expect(hash1).ToNot(Equal(hash2))
})
It("produces valid hex-encoded hash", func() {
hash := entry.hash()
Expect(hash).To(HaveLen(32)) // MD5 hash should be 32 hex characters
Expect(hash).To(MatchRegexp("^[a-f0-9]{32}$"))
})
})
Describe("isOutdated", func() {
BeforeEach(func() {
entry.prevHash = entry.hash()
})
Context("when full scan is in progress", func() {
BeforeEach(func() {
entry.job.lib.FullScanInProgress = true
entry.job.lib.LastScanStartedAt = time.Now()
})
It("returns true when updTime is before LastScanStartedAt", func() {
entry.updTime = entry.job.lib.LastScanStartedAt.Add(-1 * time.Hour)
Expect(entry.isOutdated()).To(BeTrue())
})
It("returns false when updTime is after LastScanStartedAt", func() {
entry.updTime = entry.job.lib.LastScanStartedAt.Add(1 * time.Hour)
Expect(entry.isOutdated()).To(BeFalse())
})
It("returns false when updTime equals LastScanStartedAt", func() {
entry.updTime = entry.job.lib.LastScanStartedAt
Expect(entry.isOutdated()).To(BeFalse())
})
})
Context("when full scan is not in progress", func() {
BeforeEach(func() {
entry.job.lib.FullScanInProgress = false
})
It("returns false when hash hasn't changed", func() {
Expect(entry.isOutdated()).To(BeFalse())
})
It("returns true when hash has changed", func() {
entry.numPlaylists = 10 // Change something to change the hash
Expect(entry.isOutdated()).To(BeTrue())
})
It("returns true when prevHash is empty", func() {
entry.prevHash = ""
Expect(entry.isOutdated()).To(BeTrue())
})
})
Context("priority between conditions", func() {
BeforeEach(func() {
entry.job.lib.FullScanInProgress = true
entry.job.lib.LastScanStartedAt = time.Now()
entry.updTime = entry.job.lib.LastScanStartedAt.Add(-1 * time.Hour)
})
It("returns true for full scan condition even when hash hasn't changed", func() {
// Hash is the same but full scan condition should take priority
Expect(entry.isOutdated()).To(BeTrue())
})
It("returns true when full scan condition is not met but hash changed", func() {
entry.updTime = entry.job.lib.LastScanStartedAt.Add(1 * time.Hour)
entry.numPlaylists = 10 // Change hash
Expect(entry.isOutdated()).To(BeTrue())
})
})
})
})
Describe("integration scenarios", func() {
It("handles complete folder lifecycle", func() {
// Create new folder entry
folderPath := "music/rock/album"
folderID := model.FolderID(lib, folderPath)
entry := newFolderEntry(job, folderID, folderPath, time.Time{}, "")
// Initially new and has no files
Expect(entry.isNew()).To(BeTrue())
Expect(entry.hasNoFiles()).To(BeTrue())
// Add some files
entry.audioFiles["track1.mp3"] = &fakeDirEntry{name: "track1.mp3"}
entry.audioFiles["track2.mp3"] = &fakeDirEntry{name: "track2.mp3"}
entry.imageFiles["cover.jpg"] = &fakeDirEntry{name: "cover.jpg"}
entry.numSubFolders = 1
entry.modTime = time.Now()
entry.imagesUpdatedAt = time.Now()
// No longer empty
Expect(entry.hasNoFiles()).To(BeFalse())
// Set previous hash to current hash (simulating it's been saved)
entry.prevHash = entry.hash()
entry.updTime = time.Now()
// Should not be new or outdated
Expect(entry.isNew()).To(BeFalse())
Expect(entry.isOutdated()).To(BeFalse())
// Convert to model folder
folder := entry.toFolder()
Expect(folder.NumAudioFiles).To(Equal(2))
Expect(folder.ImageFiles).To(HaveLen(1))
Expect(folder.Hash).To(Equal(entry.hash()))
// Modify folder and verify it becomes outdated
entry.audioFiles["track3.mp3"] = &fakeDirEntry{name: "track3.mp3"}
Expect(entry.isOutdated()).To(BeTrue())
})
})
})
// fakeDirEntry implements fs.DirEntry for testing
type fakeDirEntry struct {
name string
isDir bool
typ fs.FileMode
fileInfo fs.FileInfo
}
func (f *fakeDirEntry) Name() string {
return f.name
}
func (f *fakeDirEntry) IsDir() bool {
return f.isDir
}
func (f *fakeDirEntry) Type() fs.FileMode {
return f.typ
}
func (f *fakeDirEntry) Info() (fs.FileInfo, error) {
if f.fileInfo != nil {
return f.fileInfo, nil
}
return &fakeFileInfo{
name: f.name,
isDir: f.isDir,
mode: f.typ,
}, nil
}
// fakeFileInfo implements fs.FileInfo for testing
type fakeFileInfo struct {
name string
size int64
mode fs.FileMode
modTime time.Time
isDir bool
}
func (f *fakeFileInfo) Name() string { return f.name }
func (f *fakeFileInfo) Size() int64 { return f.size }
func (f *fakeFileInfo) Mode() fs.FileMode { return f.mode }
func (f *fakeFileInfo) ModTime() time.Time { return f.modTime }
func (f *fakeFileInfo) IsDir() bool { return f.isDir }
func (f *fakeFileInfo) Sys() any { return nil }