diff --git a/core/artwork/e2e/album_test.go b/core/artwork/e2e/album_test.go index 5e61684cc..8e5bb1cf5 100644 --- a/core/artwork/e2e/album_test.go +++ b/core/artwork/e2e/album_test.go @@ -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"))) + }) }) diff --git a/core/artwork/e2e/suite_test.go b/core/artwork/e2e/suite_test.go index 06cc05b6f..7860100a9 100644 --- a/core/artwork/e2e/suite_test.go +++ b/core/artwork/e2e/suite_test.go @@ -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{}) diff --git a/model/artwork_id.go b/model/artwork_id.go index 1bd146c1f..041d72e12 100644 --- a/model/artwork_id.go +++ b/model/artwork_id.go @@ -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, } } diff --git a/scanner/phase_1_folders.go b/scanner/phase_1_folders.go index 5e898590b..b204bb6ae 100644 --- a/scanner/phase_1_folders.go +++ b/scanner/phase_1_folders.go @@ -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] diff --git a/ui/src/subsonic/index.js b/ui/src/subsonic/index.js index 7d93972e0..a436e3f7d 100644 --- a/ui/src/subsonic/index.js +++ b/ui/src/subsonic/index.js @@ -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 }), }