mirror of
https://github.com/navidrome/navidrome.git
synced 2025-08-10 00:52:20 +00:00
fix(scanner): improve folderEntry methods and hashing logic for better change detection
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
@@ -8,7 +8,6 @@ import (
|
||||
"io/fs"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core"
|
||||
@@ -54,13 +53,24 @@ type folderEntry struct {
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
folder := model.NewFolder(f.job.lib, f.path)
|
||||
folder.NumAudioFiles = len(f.audioFiles)
|
||||
@@ -74,23 +84,37 @@ func (f *folderEntry) toFolder() *model.Folder {
|
||||
}
|
||||
|
||||
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))
|
||||
slices.Sort(audioKeys)
|
||||
imageKeys := slices.Collect(maps.Keys(f.imageFiles))
|
||||
slices.Sort(imageKeys)
|
||||
|
||||
h := md5.New()
|
||||
_, _ = io.WriteString(h, f.modTime.UTC().String())
|
||||
_, _ = io.WriteString(h, strings.Join(audioKeys, ","))
|
||||
_, _ = io.WriteString(h, strings.Join(imageKeys, ","))
|
||||
fmt.Fprintf(h, "%d-%d", f.numPlaylists, f.numSubFolders)
|
||||
_, _ = io.WriteString(h, f.imagesUpdatedAt.UTC().String())
|
||||
// Include audio files with their size and modtime
|
||||
for _, key := range audioKeys {
|
||||
_, _ = io.WriteString(h, key)
|
||||
if info, err := f.audioFiles[key].Info(); err == nil {
|
||||
_, _ = fmt.Fprintf(h, ":%d:%s", info.Size(), info.ModTime().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))
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
BeforeEach(func() {
|
||||
@@ -102,9 +102,9 @@ var _ = Describe("folder_entry", func() {
|
||||
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
|
||||
Expect(entry.hasNoFiles()).To(BeFalse())
|
||||
Expect(entry.hasNoFiles()).To(BeTrue())
|
||||
})
|
||||
|
||||
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() {
|
||||
It("returns true when updTime is zero", func() {
|
||||
entry.updTime = time.Time{}
|
||||
@@ -268,6 +282,104 @@ var _ = Describe("folder_entry", func() {
|
||||
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
|
||||
@@ -386,9 +498,10 @@ var _ = Describe("folder_entry", func() {
|
||||
|
||||
// fakeDirEntry implements fs.DirEntry for testing
|
||||
type fakeDirEntry struct {
|
||||
name string
|
||||
isDir bool
|
||||
typ fs.FileMode
|
||||
name string
|
||||
isDir bool
|
||||
typ fs.FileMode
|
||||
fileInfo fs.FileInfo
|
||||
}
|
||||
|
||||
func (f *fakeDirEntry) Name() string {
|
||||
@@ -404,6 +517,9 @@ func (f *fakeDirEntry) Type() fs.FileMode {
|
||||
}
|
||||
|
||||
func (f *fakeDirEntry) Info() (fs.FileInfo, error) {
|
||||
if f.fileInfo != nil {
|
||||
return f.fileInfo, nil
|
||||
}
|
||||
return &fakeFileInfo{
|
||||
name: f.name,
|
||||
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)
|
||||
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++
|
||||
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) {
|
||||
logCall := log.Info
|
||||
if entry.hasNoFiles() {
|
||||
if entry.isEmpty() {
|
||||
logCall = log.Trace
|
||||
}
|
||||
logCall(p.ctx, "Scanner: Completed processing folder",
|
||||
|
||||
Reference in New Issue
Block a user