From 3a8505584b46fbb47850f930a9ef297ef98c6311 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 17 Jul 2026 12:27:13 -0400 Subject: [PATCH] fix(artwork): hash the cached artwork bytes, not a fresh source read Generated playlist mosaics pick albums with random(), so a direct source read produced a different image than the cached one clients download, and the persisted blurhash described a mosaic nobody sees. The worker now reads through the image cache and detects placeholders by byte equality with the embedded assets (cached reads carry no source path). --- core/artwork/blurhash_updater.go | 41 ++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/core/artwork/blurhash_updater.go b/core/artwork/blurhash_updater.go index e2d7c2f4b..cbbbe869a 100644 --- a/core/artwork/blurhash_updater.go +++ b/core/artwork/blurhash_updater.go @@ -1,9 +1,11 @@ package artwork import ( + "bytes" "context" "fmt" "image" + "io" "sync" "time" @@ -12,6 +14,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/resources" ) // enqueueRequest carries the staleness signals seen at serve time: force (image-cache miss) and @@ -244,17 +247,21 @@ func (u *blurHashUpdater) computeFromArtwork(ctx context.Context, artID model.Ar if err != nil { return "", err } - // Reads straight from the source (not artwork.Get): no re-enqueue, and the returned path - // identifies placeholder artwork, which must never be persisted as an entity's blurhash. - r, path, err := artReader.Reader(ctx) + // Reads via the cache (not artwork.Get, so no re-enqueue): generated playlist mosaics are + // random per generation, and the hash must describe the bytes clients actually download. + r, err := u.a.cache.Get(ctx, artReader) if err != nil { return "", err } defer r.Close() - if path == consts.PlaceholderAlbumArt || path == consts.PlaceholderArtistArt { + data, err := io.ReadAll(r) + if err != nil { + return "", err + } + if isPlaceholder(data) { return "", nil } - img, _, err := image.Decode(r) + img, _, err := image.Decode(bytes.NewReader(data)) if err != nil { return "", err } @@ -263,6 +270,30 @@ func (u *blurHashUpdater) computeFromArtwork(ctx context.Context, artID model.Ar return blurhash.Encode(img, x, y) } +// isPlaceholder byte-compares against the embedded placeholder assets: placeholder artwork must +// never be persisted as an entity's blurhash, and cached reads carry no source path to check. +func isPlaceholder(data []byte) bool { + for _, p := range placeholderImages() { + if bytes.Equal(data, p) { + return true + } + } + return false +} + +var placeholderImages = sync.OnceValue(func() [][]byte { + var imgs [][]byte + for _, name := range []string{consts.PlaceholderAlbumArt, consts.PlaceholderArtistArt} { + if f, err := resources.FS().Open(name); err == nil { + if data, err := io.ReadAll(f); err == nil { + imgs = append(imgs, data) + } + _ = f.Close() + } + } + return imgs +}) + func (u *blurHashUpdater) persist(ctx context.Context, artID model.ArtworkID, hash string, version time.Time) error { switch artID.Kind { case model.KindAlbumArtwork: