mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 02:17:25 +02:00
A quick scan only rewrites a folder when its hash changes, and the hash did not depend on PlaylistsPath. Folders indexed while their playlists were ignored (the Windows bug in #6276, or an older PlaylistsPath) kept num_playlists = 0 when a cover image or a subfolder kept their row, so their playlists were never imported without a full scan. The hash now includes whether PlaylistsPath includes the folder, only for folders with playlist files, so other folders keep their hash. Each folder with playlists is rescanned once after upgrading. Tests scan real folders: recovery on an unchanged quick scan (playlist only, cover image, subfolder), full scan, touched playlist, a folder that stays excluded, one that becomes excluded, and a folder without playlists that is not rescanned.
155 lines
4.9 KiB
Go
155 lines
4.9 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(),
|
|
)
|
|
// Lets a quick scan update num_playlists when PlaylistsPath starts or stops including the folder
|
|
if len(f.playlistFiles) > 0 {
|
|
_, _ = fmt.Fprintf(h, ":%t", playlists.InPath(*model.NewFolder(f.job.lib, f.path)))
|
|
}
|
|
|
|
// 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))
|
|
}
|