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)) }