feat(artwork): compute and persist blurhashes asynchronously on artwork serve

This commit is contained in:
Deluan 2026-07-16 00:15:12 -04:00
parent f763ebff5b
commit 9f9019df07
3 changed files with 268 additions and 5 deletions

View File

@ -25,14 +25,17 @@ type Artwork interface {
}
func NewArtwork(ds model.DataStore, cache cache.FileCache, ffmpeg ffmpeg.FFmpeg, provider external.Provider) Artwork {
return &artwork{ds: ds, cache: cache, ffmpeg: ffmpeg, provider: provider}
a := &artwork{ds: ds, cache: cache, ffmpeg: ffmpeg, provider: provider}
a.blurHashes = newBlurHashUpdater(a)
return a
}
type artwork struct {
ds model.DataStore
cache cache.FileCache
ffmpeg ffmpeg.FFmpeg
provider external.Provider
ds model.DataStore
cache cache.FileCache
ffmpeg ffmpeg.FFmpeg
provider external.Provider
blurHashes *blurHashUpdater
}
type artworkReader interface {
@ -70,6 +73,9 @@ func (a *artwork) Get(ctx context.Context, artID model.ArtworkID, size int, squa
}
return nil, time.Time{}, err
}
if a.blurHashes != nil {
a.blurHashes.Enqueue(artID)
}
return r, artReader.LastUpdated(), nil
}

View File

@ -0,0 +1,185 @@
package artwork
import (
"context"
"image"
"sync"
"time"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/artwork/blurhash"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/resources"
)
// blurHashUpdater keeps stored blurhashes in sync with the artwork actually served. Enqueue is
// cheap (dedup map insert); the worker re-checks freshness against the DB and only decodes when
// the hash is missing or was computed from an older artwork version.
type blurHashUpdater struct {
a *artwork
mutex sync.Mutex
buffer map[model.ArtworkID]struct{}
wake chan struct{}
}
func newBlurHashUpdater(a *artwork) *blurHashUpdater {
u := &blurHashUpdater{
a: a,
buffer: make(map[model.ArtworkID]struct{}),
wake: make(chan struct{}, 1),
}
// Playlist artwork readers require a user in the context.
ctx := request.WithUser(context.TODO(), model.User{IsAdmin: true})
go u.run(ctx)
return u
}
func (u *blurHashUpdater) Enqueue(artID model.ArtworkID) {
switch artID.Kind {
case model.KindAlbumArtwork, model.KindArtistArtwork, model.KindPlaylistArtwork:
default:
return
}
u.mutex.Lock()
u.buffer[artID] = struct{}{}
u.mutex.Unlock()
select {
case u.wake <- struct{}{}:
default:
}
}
func (u *blurHashUpdater) run(ctx context.Context) {
for range u.wake {
for {
artID, ok := u.next()
if !ok {
break
}
u.process(ctx, artID)
}
}
}
func (u *blurHashUpdater) next() (model.ArtworkID, bool) {
u.mutex.Lock()
defer u.mutex.Unlock()
for artID := range u.buffer {
delete(u.buffer, artID)
return artID, true
}
return model.ArtworkID{}, false
}
func (u *blurHashUpdater) process(ctx context.Context, artID model.ArtworkID) {
// Artwork readers can touch storage, agents and plugins; a panic here must not kill the server.
defer func() {
if r := recover(); r != nil {
log.Error(ctx, "BlurHash: recovered from panic", "artID", artID, "panic", r)
}
}()
stored, storedAt, version, err := u.loadState(ctx, artID)
if err != nil {
log.Trace(ctx, "BlurHash: could not load entity", "artID", artID, err)
return
}
if stored != "" && storedAt != nil && storedAt.Equal(version) {
return
}
hash, err := u.computeFromArtwork(ctx, artID)
if err != nil || hash == "" {
log.Trace(ctx, "BlurHash: skipping", "artID", artID, err)
return
}
if err := u.persist(ctx, artID, hash, version); err != nil {
log.Warn(ctx, "BlurHash: error persisting", "artID", artID, err)
}
}
func (u *blurHashUpdater) loadState(ctx context.Context, artID model.ArtworkID) (string, *time.Time, time.Time, error) {
switch artID.Kind {
case model.KindAlbumArtwork:
al, err := u.a.ds.Album(ctx).Get(artID.ID)
if err != nil {
return "", nil, time.Time{}, err
}
return al.BlurHash, al.BlurHashUpdatedAt, al.ArtworkUpdatedAt(), nil
case model.KindArtistArtwork:
ar, err := u.a.ds.Artist(ctx).Get(artID.ID)
if err != nil {
return "", nil, time.Time{}, err
}
return ar.BlurHash, ar.BlurHashUpdatedAt, ar.ArtworkUpdatedAt(), nil
case model.KindPlaylistArtwork:
pl, err := u.a.ds.Playlist(ctx).Get(artID.ID)
if err != nil {
return "", nil, time.Time{}, err
}
return pl.BlurHash, pl.BlurHashUpdatedAt, pl.ArtworkUpdatedAt(), nil
}
return "", nil, time.Time{}, model.ErrNotFound
}
func (u *blurHashUpdater) computeFromArtwork(ctx context.Context, artID model.ArtworkID) (string, error) {
artReader, err := u.a.getArtworkReader(ctx, artID, 0, false)
if err != nil {
return "", err
}
// Bypasses artwork.Get so the worker's own fetch is never re-enqueued.
r, err := u.a.cache.Get(ctx, artReader)
if err != nil {
return "", err
}
defer r.Close()
img, _, err := image.Decode(r)
if err != nil {
return "", err
}
b := img.Bounds()
x, y := blurhash.Components(b.Dx(), b.Dy())
hash, err := blurhash.Encode(img, x, y)
if err != nil {
return "", err
}
if _, isPlaceholder := placeholderHashes()[hash]; isPlaceholder {
return "", nil
}
return hash, nil
}
func (u *blurHashUpdater) persist(ctx context.Context, artID model.ArtworkID, hash string, version time.Time) error {
switch artID.Kind {
case model.KindAlbumArtwork:
return u.a.ds.Album(ctx).UpdateBlurHash(artID.ID, hash, version)
case model.KindArtistArtwork:
return u.a.ds.Artist(ctx).UpdateBlurHash(artID.ID, hash, version)
case model.KindPlaylistArtwork:
return u.a.ds.Playlist(ctx).UpdateBlurHash(artID.ID, hash, version)
}
return nil
}
// placeholderHashes identifies placeholder bytes by their hash, since cached reads lose the
// source path; placeholder artwork must never be persisted as an entity's blurhash.
var placeholderHashes = sync.OnceValue(func() map[string]struct{} {
hashes := make(map[string]struct{})
for _, name := range []string{consts.PlaceholderAlbumArt, consts.PlaceholderArtistArt} {
f, err := resources.FS().Open(name)
if err != nil {
continue
}
img, _, err := image.Decode(f)
_ = f.Close()
if err != nil {
continue
}
b := img.Bounds()
x, y := blurhash.Components(b.Dx(), b.Dy())
if hash, err := blurhash.Encode(img, x, y); err == nil {
hashes[hash] = struct{}{}
}
}
return hashes
})

View File

@ -0,0 +1,72 @@
package artwork
import (
"time"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("blurHashUpdater", func() {
var u *blurHashUpdater
var ds *tests.MockDataStore
BeforeEach(func() {
ds = &tests.MockDataStore{}
// No run() goroutine: tests drive next()/process() directly.
u = &blurHashUpdater{
a: &artwork{ds: ds},
buffer: make(map[model.ArtworkID]struct{}),
wake: make(chan struct{}, 1),
}
})
Describe("Enqueue", func() {
It("accepts album, artist and playlist artwork and dedups", func() {
id := model.Album{ID: "al-1"}.CoverArtID()
u.Enqueue(id)
u.Enqueue(id)
u.Enqueue(model.Artist{ID: "ar-1"}.CoverArtID())
Expect(u.buffer).To(HaveLen(2))
})
It("ignores other artwork kinds", func() {
u.Enqueue(model.ArtworkID{Kind: model.KindMediaFileArtwork, ID: "mf-1"})
u.Enqueue(model.ArtworkID{Kind: model.KindRadioArtwork, ID: "ra-1"})
Expect(u.buffer).To(BeEmpty())
})
})
Describe("process", func() {
It("skips entities whose stored hash matches the current artwork version", func() {
version := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
al := model.Album{ID: "al-1", UpdatedAt: version, BlurHash: "LEHV6nWB2yk8", BlurHashUpdatedAt: &version}
repo := tests.CreateMockAlbumRepo()
repo.SetData(model.Albums{al})
ds.MockedAlbum = repo
// u.a has no cache: if process tried to compute, it would panic. Not panicking proves the skip.
Expect(func() { u.process(GinkgoT().Context(), al.CoverArtID()) }).ToNot(Panic())
stored, err := ds.Album(GinkgoT().Context()).Get("al-1")
Expect(err).ToNot(HaveOccurred())
Expect(stored.BlurHash).To(Equal("LEHV6nWB2yk8"))
})
It("does nothing when the entity is gone", func() {
ds.MockedAlbum = tests.CreateMockAlbumRepo()
Expect(func() { u.process(GinkgoT().Context(), model.Album{ID: "missing"}.CoverArtID()) }).ToNot(Panic())
})
})
Describe("placeholderHashes", func() {
It("computes hashes for the embedded placeholder images", func() {
hashes := placeholderHashes()
Expect(hashes).To(HaveLen(2))
for h := range hashes {
Expect(len(h)).To(BeNumerically(">", 6))
}
})
})
})