navidrome/core/artwork/reader_mediafile.go
Deluan 35d08a25a3 fix(album): don't wipe covers on album merges; bust disc/song art caches
Adversarial review findings:

- CopyAttributes copied uploaded_image/cover_art_updated_at unconditionally,
  so merging an album without a cover into one with a cover (retag onto an
  existing album ID, or phase-2 moved-track matching) blanked the
  destination's uploaded cover. Skip empty/NULL source values, mirroring the
  created_at guard, and make the mock faithful to the same semantics.

- The disc and mediafile artwork readers did not fold cover_art_updated_at
  into their cache keys, so song/disc art reached through the fromAlbum
  fallback kept serving stale bytes after a cover upload or delete.

- The UI cover cache-buster compared timestamps lexicographically; mixed
  offset/fractional formats could pick the older one. Compare as dates.
2026-07-17 21:34:15 -04:00

85 lines
2.2 KiB
Go

package artwork
import (
"context"
"fmt"
"io"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/model"
)
type mediafileArtworkReader struct {
cacheKey
a *artwork
mediafile model.MediaFile
album model.Album
lib libraryView
}
func newMediafileArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID) (*mediafileArtworkReader, error) {
mf, err := artwork.ds.MediaFile(ctx).Get(artID.ID)
if err != nil {
return nil, err
}
al, err := artwork.ds.Album(ctx).Get(mf.AlbumID)
if err != nil {
return nil, err
}
_, _, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, artwork.ds, *al)
if err != nil {
return nil, err
}
lib, err := loadLibraryView(ctx, artwork.ds, mf.LibraryID)
if err != nil {
return nil, err
}
a := &mediafileArtworkReader{
a: artwork,
mediafile: *mf,
album: *al,
lib: lib,
}
a.cacheKey.artID = artID
a.cacheKey.lastUpdate = mf.UpdatedAt
if al.UpdatedAt.After(a.cacheKey.lastUpdate) {
a.cacheKey.lastUpdate = al.UpdatedAt
}
if imagesUpdatedAt != nil && imagesUpdatedAt.After(a.cacheKey.lastUpdate) {
a.cacheKey.lastUpdate = *imagesUpdatedAt
}
if al.CoverArtUpdatedAt != nil && al.CoverArtUpdatedAt.After(a.cacheKey.lastUpdate) {
a.cacheKey.lastUpdate = *al.CoverArtUpdatedAt
}
return a, nil
}
func (a *mediafileArtworkReader) Key() string {
return fmt.Sprintf(
"%s.%t",
a.cacheKey.Key(),
conf.Server.EnableMediaFileCoverArt,
)
}
func (a *mediafileArtworkReader) LastUpdated() time.Time {
return a.lastUpdate
}
func (a *mediafileArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
var ff []sourceFunc
if a.mediafile.CoverArtID().Kind == model.KindMediaFileArtwork {
ff = []sourceFunc{
fromTag(ctx, a.lib.FS, a.mediafile.Path),
fromFFmpegTag(ctx, a.a.ffmpeg, a.lib.Abs(a.mediafile.Path)),
}
}
// For multi-disc albums, fall back to disc artwork first; for single-disc albums,
// skip disc resolution (it would just fall through to album art anyway).
if len(a.album.Discs) > 1 {
ff = append(ff, fromAlbum(ctx, a.a, a.mediafile.DiscCoverArtID()))
} else {
ff = append(ff, fromAlbum(ctx, a.a, a.mediafile.AlbumCoverArtID()))
}
return selectImageReader(ctx, a.artID, ff...)
}