mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
* 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>
152 lines
4.6 KiB
Go
152 lines
4.6 KiB
Go
package scanner
|
|
|
|
import (
|
|
"crypto/md5"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"maps"
|
|
"slices"
|
|
"time"
|
|
|
|
"github.com/navidrome/navidrome/core/artwork"
|
|
"github.com/navidrome/navidrome/core/playlists"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/utils/chrono"
|
|
)
|
|
|
|
func newFolderEntry(job *scanJob, id, path string, info model.FolderUpdateInfo) *folderEntry {
|
|
f := &folderEntry{
|
|
id: id,
|
|
job: job,
|
|
path: path,
|
|
audioFiles: make(map[string]fs.DirEntry),
|
|
imageFiles: make(map[string]fs.DirEntry),
|
|
playlistFiles: make(map[string]fs.DirEntry),
|
|
albumIDMap: make(map[string]string),
|
|
updTime: info.UpdatedAt,
|
|
prevHash: info.Hash,
|
|
prevImageFiles: info.ImageFiles,
|
|
prevImagesUpdatedAt: info.ImagesUpdatedAt,
|
|
}
|
|
return f
|
|
}
|
|
|
|
type folderEntry struct {
|
|
job *scanJob
|
|
elapsed chrono.Meter
|
|
path string // Full path
|
|
id string // DB ID
|
|
modTime time.Time // From FS
|
|
updTime time.Time // from DB
|
|
audioFiles map[string]fs.DirEntry
|
|
imageFiles map[string]fs.DirEntry
|
|
playlistFiles map[string]fs.DirEntry
|
|
numSubFolders int
|
|
imagesUpdatedAt time.Time
|
|
prevHash string // Previous hash from DB
|
|
// Previous image state from DB, to detect image-only changes
|
|
prevImageFiles []string
|
|
prevImagesUpdatedAt time.Time
|
|
tracks model.MediaFiles
|
|
albums model.Albums
|
|
albumIDMap map[string]string
|
|
artists model.Artists
|
|
tags model.TagList
|
|
missingTracks []*model.MediaFile
|
|
}
|
|
|
|
func (f *folderEntry) hasNoFiles() bool {
|
|
return len(f.audioFiles) == 0 && len(f.imageFiles) == 0 && len(f.playlistFiles) == 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()
|
|
}
|
|
|
|
// imagesChanged reports whether the folder's image files differ from the previously persisted
|
|
// state, and whether an artist image is involved (present in the old or the new list).
|
|
func (f *folderEntry) imagesChanged() (changed, artistImage bool) {
|
|
newNames := slices.Sorted(maps.Keys(f.imageFiles))
|
|
prevNames := slices.Sorted(slices.Values(f.prevImageFiles))
|
|
// Both empty also skips the timestamp check, which is noise for image-less folders.
|
|
if len(prevNames) == 0 && len(newNames) == 0 {
|
|
return false, false
|
|
}
|
|
if slices.Equal(prevNames, newNames) && f.prevImagesUpdatedAt.Equal(f.imagesUpdatedAt) {
|
|
return false, false
|
|
}
|
|
return true, slices.ContainsFunc(slices.Concat(prevNames, newNames), artwork.IsArtistImageFile)
|
|
}
|
|
|
|
func (f *folderEntry) toFolder() *model.Folder {
|
|
folder := model.NewFolder(f.job.lib, f.path)
|
|
folder.NumAudioFiles = len(f.audioFiles)
|
|
if playlists.InPath(*folder) {
|
|
folder.NumPlaylists = len(f.playlistFiles)
|
|
}
|
|
folder.ImageFiles = slices.Collect(maps.Keys(f.imageFiles))
|
|
folder.ImagesUpdatedAt = f.imagesUpdatedAt
|
|
folder.Hash = f.hash()
|
|
return folder
|
|
}
|
|
|
|
func (f *folderEntry) hash() string {
|
|
h := md5.New()
|
|
_, _ = fmt.Fprintf(
|
|
h,
|
|
"%s:%d:%d:%s",
|
|
f.modTime.UTC(),
|
|
len(f.playlistFiles), // redundant with the loop below, but dropping it re-hashes every folder
|
|
f.numSubFolders,
|
|
f.imagesUpdatedAt.UTC(),
|
|
)
|
|
|
|
// Sort the keys of audio, image and playlist 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)
|
|
playlistKeys := slices.Collect(maps.Keys(f.playlistFiles))
|
|
slices.Sort(playlistKeys)
|
|
|
|
// 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())
|
|
}
|
|
}
|
|
|
|
// Include playlist files, so a content edit is detected even when the folder's
|
|
// mtime is preserved (rsync -a) or the playlist is not the newest file.
|
|
for _, key := range playlistKeys {
|
|
_, _ = io.WriteString(h, key)
|
|
if info, err := f.playlistFiles[key].Info(); err == nil {
|
|
_, _ = fmt.Fprintf(h, ":%d:%s", info.Size(), info.ModTime().UTC().String())
|
|
}
|
|
}
|
|
|
|
return hex.EncodeToString(h.Sum(nil))
|
|
}
|