navidrome/scanner/folder_entry_test.go
Junker der Provinz dff9e47c2e
fix(scanner): detect in-place playlist edits via the folder content hash (#5914)
* fix(scanner): detect in-place playlist edits via the folder content hash

Signed-off-by: junkerderprovinz <jdp@braethoria.com>

* docs(scanner): clarify the playlist entries in the folder hash

Shorten the comment on the playlist loop, and record why the playlist count
stays in the hash header: it is redundant with the loop for change detection,
but removing it changes the hashed byte stream for every folder, including
folders without playlists, which would mark every folder outdated on the first
scan after upgrade.

Signed-off-by: junkerderprovinz <jdp@braethoria.com>

* test(scanner): pin filename and size into the playlist hash assertions

The playlist size test called time.Now() twice, so the modtime differed too
and carried the assertion — dropping info.Size() from the hash left the suite
green. It now shares one baseTime. A new rename test swaps the map key with
count, size and modtime held constant, so dropping the filename from the hash
fails. Both mutations were verified to fail before this change and pass after.

---------

Signed-off-by: junkerderprovinz <jdp@braethoria.com>
Co-authored-by: Deluan <deluan@navidrome.org>
2026-08-19 13:11:00 -04:00

608 lines
17 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)
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.playlistFiles).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, model.FolderUpdateInfo{})
})
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.playlistFiles["list.m3u"] = &fakeDirEntry{name: "list.m3u"}
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.playlistFiles["list.m3u"] = &fakeDirEntry{name: "list.m3u"}
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.playlistFiles = map[string]fs.DirEntry{
"list1.m3u": &fakeDirEntry{name: "list1.m3u"},
"list2.m3u": &fakeDirEntry{name: "list2.m3u"},
"list3.m3u": &fakeDirEntry{name: "list3.m3u"},
}
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.playlistFiles = map[string]fs.DirEntry{
"q.m3u": &fakeDirEntry{name: "q.m3u"},
"p.m3u": &fakeDirEntry{name: "p.m3u"},
}
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"},
}
entry.playlistFiles = map[string]fs.DirEntry{
"p.m3u": &fakeDirEntry{name: "p.m3u"},
"q.m3u": &fakeDirEntry{name: "q.m3u"},
}
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 files change", func() {
hash1 := entry.hash()
entry.playlistFiles["new.m3u"] = &fakeDirEntry{name: "new.m3u"}
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 different hash when playlist file size changes", func() {
baseTime := time.Now()
entry.playlistFiles["list.m3u"] = &fakeDirEntry{
name: "list.m3u",
fileInfo: &fakeFileInfo{name: "list.m3u", size: 1000, modTime: baseTime},
}
hash1 := entry.hash()
entry.playlistFiles["list.m3u"] = &fakeDirEntry{
name: "list.m3u",
fileInfo: &fakeFileInfo{name: "list.m3u", size: 2000, modTime: baseTime},
}
hash2 := entry.hash()
Expect(hash1).ToNot(Equal(hash2))
})
It("produces different hash when playlist file modification time changes", func() {
baseTime := time.Now()
entry.playlistFiles["list.m3u"] = &fakeDirEntry{
name: "list.m3u",
fileInfo: &fakeFileInfo{name: "list.m3u", size: 1000, modTime: baseTime},
}
hash1 := entry.hash()
entry.playlistFiles["list.m3u"] = &fakeDirEntry{
name: "list.m3u",
fileInfo: &fakeFileInfo{name: "list.m3u", size: 1000, modTime: baseTime.Add(1 * time.Hour)},
}
hash2 := entry.hash()
Expect(hash1).ToNot(Equal(hash2))
})
It("produces different hash when a playlist is renamed", func() {
baseTime := time.Now()
entry.playlistFiles["old.m3u"] = &fakeDirEntry{
name: "old.m3u",
fileInfo: &fakeFileInfo{name: "old.m3u", size: 1000, modTime: baseTime},
}
hash1 := entry.hash()
delete(entry.playlistFiles, "old.m3u")
entry.playlistFiles["new.m3u"] = &fakeDirEntry{
name: "new.m3u",
fileInfo: &fakeFileInfo{name: "new.m3u", size: 1000, modTime: baseTime},
}
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.playlistFiles["list.m3u"] = &fakeDirEntry{name: "list.m3u"} // 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.playlistFiles["list.m3u"] = &fakeDirEntry{name: "list.m3u"} // 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, model.FolderUpdateInfo{})
// 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 }