fix(scanner): bust cover art cache after image-only quick scan - #5469

Signed-off-by: aym-n <ajm8982@gmail.com>
This commit is contained in:
aym-n 2026-07-18 13:54:14 +05:30
parent 64430af9ce
commit 50f20d60af
5 changed files with 72 additions and 2 deletions

View File

@ -2,6 +2,7 @@ package artworke2e_test
import (
"testing/fstest"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/model"
@ -466,4 +467,19 @@ var _ = Describe("Album artwork resolution", func() {
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover")))
})
})
// https://github.com/navidrome/navidrome/issues/5469
It("updates cover art after an image-only quick scan", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{"Artist/Album/01 - Track.mp3": trackFile(1, "Track")})
scan()
alBefore := firstAlbum()
fakeFS.Add("Artist/Album/cover.jpg", imageFile("new-cover"), alBefore.UpdatedAt.Add(time.Hour))
scanQuick()
alAfter := firstAlbum()
Expect(alAfter.ImportedAt.After(alBefore.ImportedAt)).To(BeTrue())
Expect(readArtwork(alAfter.CoverArtID())).To(Equal(imageBytes("new-cover")))
})
})

View File

@ -98,6 +98,14 @@ func scan() {
Expect(err).ToNot(HaveOccurred())
}
func scanQuick() {
GinkgoHelper()
s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance())
_, err := s.ScanAll(ctx, false)
Expect(err).ToNot(HaveOccurred())
}
func firstAlbum() model.Album {
GinkgoHelper()
albums, err := ds.Album(ctx).GetAll(model.QueryOptions{})

View File

@ -112,10 +112,14 @@ func ParseDiscArtworkID(id string) (albumID string, discNumber int, err error) {
}
func artworkIDFromAlbum(al Album) ArtworkID {
lastUpdate := al.UpdatedAt
if al.ImportedAt.After(lastUpdate) {
lastUpdate = al.ImportedAt
}
return ArtworkID{
Kind: KindAlbumArtwork,
ID: al.ID,
LastUpdate: al.UpdatedAt,
LastUpdate: lastUpdate,
}
}

View File

@ -342,6 +342,9 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error)
albumRepo := tx.Album(p.ctx)
mfRepo := tx.MediaFile(p.ctx)
prevFolder, _ := folderRepo.Get(entry.id)
imagesChanged := folderImagesChanged(prevFolder, entry)
// Save folder to DB
folder := entry.toFolder()
err := folderRepo.Put(folder)
@ -415,6 +418,13 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error)
return err
}
}
// #5469: bump ImportedAt when sidecar images change so cover URLs invalidate.
if imagesChanged {
if err := p.touchAlbumsForFolderImages(mfRepo, albumRepo, entry); err != nil {
return err
}
}
return nil
}, "scanner: persist changes")
if err != nil {
@ -431,6 +441,34 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error)
return entry, err
}
func folderImagesChanged(prev *model.Folder, entry *folderEntry) bool {
if prev == nil {
return len(entry.imageFiles) > 0
}
newFiles := slices.Collect(maps.Keys(entry.imageFiles))
slices.Sort(newFiles)
prevFiles := slices.Clone(prev.ImageFiles)
slices.Sort(prevFiles)
return !slices.Equal(prevFiles, newFiles) || !prev.ImagesUpdatedAt.Equal(entry.imagesUpdatedAt)
}
func (p *phaseFolders) touchAlbumsForFolderImages(
mfRepo model.MediaFileRepository,
albumRepo model.AlbumRepository,
entry *folderEntry,
) error {
mfs, err := mfRepo.GetAll(model.QueryOptions{
Filters: squirrel.And{
squirrel.Eq{"folder_id": entry.id},
squirrel.Eq{"missing": false},
},
})
if err != nil {
return err
}
return albumRepo.Touch(slice.Unique(slice.Map(mfs, func(mf model.MediaFile) string { return mf.AlbumID }))...)
}
// persistAlbum persists the given album to the database, and reassigns annotations from the previous album ID
func (p *phaseFolders) persistAlbum(repo model.AlbumRepository, a *model.Album, idMap map[string]string) error {
prevID := idMap[a.ID]

View File

@ -81,8 +81,12 @@ const getAvatarUrl = (username, size) =>
)
const getCoverArtUrl = (record, size, square) => {
const bust = [record?.updatedAt, record?.importedAt]
.filter(Boolean)
.sort()
.at(-1)
const options = {
...(record.updatedAt && { _: record.updatedAt }),
...(bust && { _: bust }),
...(size && { size }),
...(square && { square }),
}