diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go index b8c395c12..51fefcc18 100644 --- a/core/artwork/artwork.go +++ b/core/artwork/artwork.go @@ -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 } diff --git a/core/artwork/blurhash_updater.go b/core/artwork/blurhash_updater.go new file mode 100644 index 000000000..e031d82a4 --- /dev/null +++ b/core/artwork/blurhash_updater.go @@ -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 +}) diff --git a/core/artwork/blurhash_updater_internal_test.go b/core/artwork/blurhash_updater_internal_test.go new file mode 100644 index 000000000..36c06686e --- /dev/null +++ b/core/artwork/blurhash_updater_internal_test.go @@ -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)) + } + }) + }) +})