fix(scanner): improve folderEntry methods and hashing logic for better change detection

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan
2025-06-14 12:35:28 -04:00
parent 6f749b387b
commit 44834204de
3 changed files with 163 additions and 23 deletions
+39 -15
View File
@@ -8,7 +8,6 @@ import (
"io/fs" "io/fs"
"maps" "maps"
"slices" "slices"
"strings"
"time" "time"
"github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core"
@@ -54,13 +53,24 @@ type folderEntry struct {
} }
func (f *folderEntry) hasNoFiles() bool { func (f *folderEntry) hasNoFiles() bool {
return len(f.audioFiles) == 0 && len(f.imageFiles) == 0 && f.numPlaylists == 0 && f.numSubFolders == 0 return len(f.audioFiles) == 0 && len(f.imageFiles) == 0 && f.numPlaylists == 0
}
func (f *folderEntry) isEmpty() bool {
return f.hasNoFiles() && f.numSubFolders == 0
} }
func (f *folderEntry) isNew() bool { func (f *folderEntry) isNew() bool {
return f.updTime.IsZero() return f.updTime.IsZero()
} }
func (f *folderEntry) isOutdated() bool {
if f.job.lib.FullScanInProgress && f.updTime.Before(f.job.lib.LastScanStartedAt) {
return true
}
return f.prevHash != f.hash()
}
func (f *folderEntry) toFolder() *model.Folder { func (f *folderEntry) toFolder() *model.Folder {
folder := model.NewFolder(f.job.lib, f.path) folder := model.NewFolder(f.job.lib, f.path)
folder.NumAudioFiles = len(f.audioFiles) folder.NumAudioFiles = len(f.audioFiles)
@@ -74,23 +84,37 @@ func (f *folderEntry) toFolder() *model.Folder {
} }
func (f *folderEntry) hash() string { func (f *folderEntry) hash() string {
h := md5.New()
_, _ = fmt.Fprintf(
h,
"%s:%d:%d:%s",
f.modTime.UTC(),
f.numPlaylists,
f.numSubFolders,
f.imagesUpdatedAt.UTC(),
)
// Sort the keys of audio and image files to ensure consistent hashing
audioKeys := slices.Collect(maps.Keys(f.audioFiles)) audioKeys := slices.Collect(maps.Keys(f.audioFiles))
slices.Sort(audioKeys) slices.Sort(audioKeys)
imageKeys := slices.Collect(maps.Keys(f.imageFiles)) imageKeys := slices.Collect(maps.Keys(f.imageFiles))
slices.Sort(imageKeys) slices.Sort(imageKeys)
h := md5.New() // Include audio files with their size and modtime
_, _ = io.WriteString(h, f.modTime.UTC().String()) for _, key := range audioKeys {
_, _ = io.WriteString(h, strings.Join(audioKeys, ",")) _, _ = io.WriteString(h, key)
_, _ = io.WriteString(h, strings.Join(imageKeys, ",")) if info, err := f.audioFiles[key].Info(); err == nil {
fmt.Fprintf(h, "%d-%d", f.numPlaylists, f.numSubFolders) _, _ = fmt.Fprintf(h, ":%d:%s", info.Size(), info.ModTime().UTC().String())
_, _ = io.WriteString(h, f.imagesUpdatedAt.UTC().String()) }
}
// Include image files with their size and modtime
for _, key := range imageKeys {
_, _ = io.WriteString(h, key)
if info, err := f.imageFiles[key].Info(); err == nil {
_, _ = fmt.Fprintf(h, ":%d:%s", info.Size(), info.ModTime().UTC().String())
}
}
return hex.EncodeToString(h.Sum(nil)) return hex.EncodeToString(h.Sum(nil))
} }
func (f *folderEntry) isOutdated() bool {
if f.job.lib.FullScanInProgress && f.updTime.Before(f.job.lib.LastScanStartedAt) {
return true
}
return f.prevHash != f.hash()
}
+122 -6
View File
@@ -75,7 +75,7 @@ var _ = Describe("folder_entry", func() {
}) })
}) })
Describe("folderEntry methods", func() { Describe("folderEntry", func() {
var entry *folderEntry var entry *folderEntry
BeforeEach(func() { BeforeEach(func() {
@@ -102,9 +102,9 @@ var _ = Describe("folder_entry", func() {
Expect(entry.hasNoFiles()).To(BeFalse()) Expect(entry.hasNoFiles()).To(BeFalse())
}) })
It("returns false when folder has subfolders", func() { It("ignores subfolders when checking for no files", func() {
entry.numSubFolders = 1 entry.numSubFolders = 1
Expect(entry.hasNoFiles()).To(BeFalse()) Expect(entry.hasNoFiles()).To(BeTrue())
}) })
It("returns false when folder has multiple types of content", func() { It("returns false when folder has multiple types of content", func() {
@@ -116,6 +116,20 @@ var _ = Describe("folder_entry", func() {
}) })
}) })
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() { Describe("isNew", func() {
It("returns true when updTime is zero", func() { It("returns true when updTime is zero", func() {
entry.updTime = time.Time{} entry.updTime = time.Time{}
@@ -268,6 +282,104 @@ var _ = Describe("folder_entry", func() {
Expect(hash1).ToNot(Equal(hash2)) 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() { It("produces valid hex-encoded hash", func() {
hash := entry.hash() hash := entry.hash()
Expect(hash).To(HaveLen(32)) // MD5 hash should be 32 hex characters Expect(hash).To(HaveLen(32)) // MD5 hash should be 32 hex characters
@@ -386,9 +498,10 @@ var _ = Describe("folder_entry", func() {
// fakeDirEntry implements fs.DirEntry for testing // fakeDirEntry implements fs.DirEntry for testing
type fakeDirEntry struct { type fakeDirEntry struct {
name string name string
isDir bool isDir bool
typ fs.FileMode typ fs.FileMode
fileInfo fs.FileInfo
} }
func (f *fakeDirEntry) Name() string { func (f *fakeDirEntry) Name() string {
@@ -404,6 +517,9 @@ func (f *fakeDirEntry) Type() fs.FileMode {
} }
func (f *fakeDirEntry) Info() (fs.FileInfo, error) { func (f *fakeDirEntry) Info() (fs.FileInfo, error) {
if f.fileInfo != nil {
return f.fileInfo, nil
}
return &fakeFileInfo{ return &fakeFileInfo{
name: f.name, name: f.name,
isDir: f.isDir, isDir: f.isDir,
+2 -2
View File
@@ -164,7 +164,7 @@ func (p *phaseFolders) producer() ppl.Producer[*folderEntry] {
log.Trace(p.ctx, "Scanner: Skipping new folder with no files", "folder", folder.path, "lib", job.lib.Name) log.Trace(p.ctx, "Scanner: Skipping new folder with no files", "folder", folder.path, "lib", job.lib.Name)
continue continue
} }
log.Trace(p.ctx, "Scanner: Detected changes in folder", "folder", folder.path, "lastUpdate", folder.modTime, "lib", job.lib.Name) log.Debug(p.ctx, "Scanner: Detected changes in folder", "folder", folder.path, "lastUpdate", folder.modTime, "lib", job.lib.Name)
} }
totalChanged++ totalChanged++
folder.elapsed.Stop() folder.elapsed.Stop()
@@ -439,7 +439,7 @@ func (p *phaseFolders) persistAlbum(repo model.AlbumRepository, a *model.Album,
func (p *phaseFolders) logFolder(entry *folderEntry) (*folderEntry, error) { func (p *phaseFolders) logFolder(entry *folderEntry) (*folderEntry, error) {
logCall := log.Info logCall := log.Info
if entry.hasNoFiles() { if entry.isEmpty() {
logCall = log.Trace logCall = log.Trace
} }
logCall(p.ctx, "Scanner: Completed processing folder", logCall(p.ctx, "Scanner: Completed processing folder",