diff --git a/scanner/folder_entry.go b/scanner/folder_entry.go index deac971ad..fc68cb561 100644 --- a/scanner/folder_entry.go +++ b/scanner/folder_entry.go @@ -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() -} diff --git a/scanner/folder_entry_test.go b/scanner/folder_entry_test.go index d88d00d7c..c6d1b2ce4 100644 --- a/scanner/folder_entry_test.go +++ b/scanner/folder_entry_test.go @@ -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, diff --git a/scanner/phase_1_folders.go b/scanner/phase_1_folders.go index 6139a3a73..2e3ff9bea 100644 --- a/scanner/phase_1_folders.go +++ b/scanner/phase_1_folders.go @@ -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",