mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
feat(artwork): compute and persist blurhashes asynchronously on artwork serve
This commit is contained in:
parent
f763ebff5b
commit
9f9019df07
@ -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
|
||||
}
|
||||
|
||||
|
||||
185
core/artwork/blurhash_updater.go
Normal file
185
core/artwork/blurhash_updater.go
Normal 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
|
||||
})
|
||||
72
core/artwork/blurhash_updater_internal_test.go
Normal file
72
core/artwork/blurhash_updater_internal_test.go
Normal 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))
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user