fix(scanner): improve folderEntry methods and hashing logic for better change detection
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
+39
-15
@@ -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()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user