From 470b4bdfa0b229cfadab173655832cbd217a16d6 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 15 Jul 2026 23:04:19 -0400 Subject: [PATCH 01/46] feat(artwork): add blurhash encoder package --- core/artwork/blurhash/blurhash.go | 152 +++++++++++++++++++ core/artwork/blurhash/blurhash_suite_test.go | 17 +++ core/artwork/blurhash/blurhash_test.go | 113 ++++++++++++++ 3 files changed, 282 insertions(+) create mode 100644 core/artwork/blurhash/blurhash.go create mode 100644 core/artwork/blurhash/blurhash_suite_test.go create mode 100644 core/artwork/blurhash/blurhash_test.go diff --git a/core/artwork/blurhash/blurhash.go b/core/artwork/blurhash/blurhash.go new file mode 100644 index 000000000..f29a295bb --- /dev/null +++ b/core/artwork/blurhash/blurhash.go @@ -0,0 +1,152 @@ +// Package blurhash implements the blurhash encoding algorithm (https://github.com/woltapp/blurhash), +// matching Jellyfin's parameters so clients tuned against Jellyfin see equivalent hashes. +package blurhash + +import ( + "errors" + "image" + "image/draw" + "math" + "strings" + + xdraw "golang.org/x/image/draw" +) + +const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~" + +// maxInputSize matches Jellyfin: larger inputs are slower with no visually discernible difference. +const maxInputSize = 128 + +// Components picks x/y component counts for an image, targeting ~16 near-square tiles (Jellyfin's formula). +func Components(width, height int) (int, int) { + if width <= 0 || height <= 0 { + return 0, 0 + } + xf := math.Sqrt(16.0 * float64(width) / float64(height)) + yf := xf * float64(height) / float64(width) + return min(int(xf)+1, 9), min(int(yf)+1, 9) +} + +// Encode returns the blurhash of img using xComp x yComp components. +func Encode(img image.Image, xComp, yComp int) (string, error) { + if xComp < 1 || xComp > 9 || yComp < 1 || yComp > 9 { + return "", errors.New("blurhash: components must be between 1 and 9") + } + img = downscale(img) + bounds := img.Bounds() + w, h := bounds.Dx(), bounds.Dy() + if w == 0 || h == 0 { + return "", errors.New("blurhash: empty image") + } + + cosX := make([][]float64, xComp) + for i := range cosX { + cosX[i] = make([]float64, w) + for x := range cosX[i] { + cosX[i][x] = math.Cos(math.Pi * float64(i) * float64(x) / float64(w)) + } + } + cosY := make([][]float64, yComp) + for j := range cosY { + cosY[j] = make([]float64, h) + for y := range cosY[j] { + cosY[j][y] = math.Cos(math.Pi * float64(j) * float64(y) / float64(h)) + } + } + + factors := make([][3]float64, xComp*yComp) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + r, g, b, _ := img.At(bounds.Min.X+x, bounds.Min.Y+y).RGBA() + lr, lg, lb := srgbToLinear(int(r>>8)), srgbToLinear(int(g>>8)), srgbToLinear(int(b>>8)) + for j := 0; j < yComp; j++ { + for i := 0; i < xComp; i++ { + basis := cosX[i][x] * cosY[j][y] + f := &factors[j*xComp+i] + f[0] += basis * lr + f[1] += basis * lg + f[2] += basis * lb + } + } + } + } + for idx := range factors { + norm := 2.0 + if idx == 0 { + norm = 1.0 + } + scale := norm / float64(w*h) + factors[idx][0] *= scale + factors[idx][1] *= scale + factors[idx][2] *= scale + } + + var sb strings.Builder + sb.WriteString(encode83((xComp-1)+(yComp-1)*9, 1)) + + ac := factors[1:] + maxVal := 1.0 + if len(ac) > 0 { + actualMax := 0.0 + for _, f := range ac { + actualMax = max(actualMax, math.Abs(f[0]), math.Abs(f[1]), math.Abs(f[2])) + } + quantMax := int(math.Max(0, math.Min(82, math.Floor(actualMax*166-0.5)))) + maxVal = float64(quantMax+1) / 166 + sb.WriteString(encode83(quantMax, 1)) + } else { + sb.WriteString(encode83(0, 1)) + } + + dc := factors[0] + sb.WriteString(encode83(linearToSRGB(dc[0])<<16|linearToSRGB(dc[1])<<8|linearToSRGB(dc[2]), 4)) + for _, f := range ac { + sb.WriteString(encode83(quantAC(f[0], maxVal)*19*19+quantAC(f[1], maxVal)*19+quantAC(f[2], maxVal), 2)) + } + return sb.String(), nil +} + +func downscale(img image.Image) image.Image { + b := img.Bounds() + w, h := b.Dx(), b.Dy() + if w <= maxInputSize && h <= maxInputSize { + return img + } + scale := float64(maxInputSize) / float64(max(w, h)) + dst := image.NewRGBA(image.Rect(0, 0, max(1, int(float64(w)*scale)), max(1, int(float64(h)*scale)))) + xdraw.ApproxBiLinear.Scale(dst, dst.Bounds(), img, b, draw.Src, nil) + return dst +} + +func quantAC(v, maxVal float64) int { + return int(math.Max(0, math.Min(18, math.Floor(signPow(v/maxVal, 0.5)*9+9.5)))) +} + +func signPow(v, exp float64) float64 { + return math.Copysign(math.Pow(math.Abs(v), exp), v) +} + +func srgbToLinear(v int) float64 { + f := float64(v) / 255 + if f <= 0.04045 { + return f / 12.92 + } + return math.Pow((f+0.055)/1.055, 2.4) +} + +func linearToSRGB(v float64) int { + v = math.Min(math.Max(0, v), 1) + if v <= 0.0031308 { + return int(v*12.92*255 + 0.5) + } + return int((1.055*math.Pow(v, 1/2.4)-0.055)*255 + 0.5) +} + +func encode83(value, length int) string { + b := make([]byte, length) + for i := length - 1; i >= 0; i-- { + b[i] = alphabet[value%83] + value /= 83 + } + return string(b) +} diff --git a/core/artwork/blurhash/blurhash_suite_test.go b/core/artwork/blurhash/blurhash_suite_test.go new file mode 100644 index 000000000..b0e96d7a7 --- /dev/null +++ b/core/artwork/blurhash/blurhash_suite_test.go @@ -0,0 +1,17 @@ +package blurhash_test + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestBlurHash(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "BlurHash Suite") +} diff --git a/core/artwork/blurhash/blurhash_test.go b/core/artwork/blurhash/blurhash_test.go new file mode 100644 index 000000000..18dd86b99 --- /dev/null +++ b/core/artwork/blurhash/blurhash_test.go @@ -0,0 +1,113 @@ +package blurhash_test + +import ( + "image" + "image/color" + "strings" + + "github.com/navidrome/navidrome/core/artwork/blurhash" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~" + +func decode83(s string) int { + v := 0 + for _, c := range s { + v = v*83 + strings.IndexRune(alphabet, c) + } + return v +} + +func solidImage(w, h int, c color.NRGBA) image.Image { + img := image.NewNRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.SetNRGBA(x, y, c) + } + } + return img +} + +func gradientImage(w, h int) image.Image { + img := image.NewNRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.SetNRGBA(x, y, color.NRGBA{R: uint8(255 * x / w), G: uint8(255 * y / h), B: 128, A: 255}) + } + } + return img +} + +var _ = Describe("Components", func() { + DescribeTable("derives component counts from aspect ratio (Jellyfin formula)", + func(w, h, expectedX, expectedY int) { + x, y := blurhash.Components(w, h) + Expect(x).To(Equal(expectedX)) + Expect(y).To(Equal(expectedY)) + }, + Entry("square album art", 600, 600, 5, 5), + Entry("small square", 1, 1, 5, 5), + Entry("landscape 16:9", 1920, 1080, 6, 4), + Entry("portrait 9:16", 1080, 1920, 4, 6), + Entry("extreme landscape capped at 9", 10000, 100, 9, 1), + Entry("zero width", 0, 600, 0, 0), + Entry("zero height", 600, 0, 0, 0), + ) +}) + +var _ = Describe("Encode", func() { + It("rejects out-of-range components", func() { + _, err := blurhash.Encode(solidImage(8, 8, color.NRGBA{A: 255}), 0, 5) + Expect(err).To(HaveOccurred()) + _, err = blurhash.Encode(solidImage(8, 8, color.NRGBA{A: 255}), 5, 10) + Expect(err).To(HaveOccurred()) + }) + + It("produces the spec-mandated length", func() { + // 1 (size flag) + 1 (max AC) + 4 (DC) + 2 per AC component + h, err := blurhash.Encode(solidImage(8, 8, color.NRGBA{R: 10, G: 20, B: 30, A: 255}), 4, 3) + Expect(err).ToNot(HaveOccurred()) + Expect(h).To(HaveLen(4 + 2 + 2*(4*3-1))) + }) + + It("encodes the size flag as the first character", func() { + h, err := blurhash.Encode(solidImage(8, 8, color.NRGBA{A: 255}), 4, 3) + Expect(err).ToNot(HaveOccurred()) + Expect(decode83(h[:1])).To(Equal((4 - 1) + (3-1)*9)) + }) + + It("stores the average color in the DC component", func() { + h, err := blurhash.Encode(solidImage(16, 16, color.NRGBA{R: 200, G: 100, B: 50, A: 255}), 4, 3) + Expect(err).ToNot(HaveOccurred()) + dc := decode83(h[2:6]) + Expect(dc >> 16).To(BeNumerically("~", 200, 1)) + Expect((dc >> 8) & 0xFF).To(BeNumerically("~", 100, 1)) + Expect(dc & 0xFF).To(BeNumerically("~", 50, 1)) + }) + + It("is deterministic", func() { + img := gradientImage(64, 64) + h1, err1 := blurhash.Encode(img, 5, 5) + h2, err2 := blurhash.Encode(img, 5, 5) + Expect(err1).ToNot(HaveOccurred()) + Expect(err2).ToNot(HaveOccurred()) + Expect(h1).To(Equal(h2)) + }) + + It("produces different hashes for different images", func() { + h1, _ := blurhash.Encode(solidImage(16, 16, color.NRGBA{R: 255, A: 255}), 4, 4) + h2, _ := blurhash.Encode(gradientImage(16, 16), 4, 4) + Expect(h1).ToNot(Equal(h2)) + }) + + It("downscales large images internally without changing the result materially", func() { + // A 1000px solid image must encode fine and carry the same DC as its small version. + big, err := blurhash.Encode(solidImage(1000, 1000, color.NRGBA{R: 60, G: 120, B: 180, A: 255}), 5, 5) + Expect(err).ToNot(HaveOccurred()) + small, err := blurhash.Encode(solidImage(16, 16, color.NRGBA{R: 60, G: 120, B: 180, A: 255}), 5, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(big[2:6]).To(Equal(small[2:6])) + }) +}) From a115726e7161e1e0cdc436f07b09427d7febe4c6 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 15 Jul 2026 23:11:14 -0400 Subject: [PATCH 02/46] feat(model): add blur_hash columns and ArtworkUpdatedAt version methods --- .../20260716030719_add_artwork_blur_hash.sql | 15 ++++++++++++++ model/album.go | 17 ++++++++++++++++ model/album_test.go | 20 +++++++++++++++++++ model/artist.go | 16 +++++++++++++++ model/artist_test.go | 16 +++++++++++++++ model/playlist.go | 7 +++++++ model/playlist_test.go | 9 +++++++++ 7 files changed, 100 insertions(+) create mode 100644 db/migrations/20260716030719_add_artwork_blur_hash.sql diff --git a/db/migrations/20260716030719_add_artwork_blur_hash.sql b/db/migrations/20260716030719_add_artwork_blur_hash.sql new file mode 100644 index 000000000..a7b75dfc5 --- /dev/null +++ b/db/migrations/20260716030719_add_artwork_blur_hash.sql @@ -0,0 +1,15 @@ +-- +goose Up +alter table album add column blur_hash varchar; +alter table album add column blur_hash_updated_at datetime; +alter table artist add column blur_hash varchar; +alter table artist add column blur_hash_updated_at datetime; +alter table playlist add column blur_hash varchar; +alter table playlist add column blur_hash_updated_at datetime; + +-- +goose Down +alter table album drop column blur_hash; +alter table album drop column blur_hash_updated_at; +alter table artist drop column blur_hash; +alter table artist drop column blur_hash_updated_at; +alter table playlist drop column blur_hash; +alter table playlist drop column blur_hash_updated_at; diff --git a/model/album.go b/model/album.go index ade7f6ee0..6030e033e 100644 --- a/model/album.go +++ b/model/album.go @@ -67,12 +67,29 @@ type Album struct { ImportedAt time.Time `structs:"imported_at" json:"importedAt" hash:"ignore"` // When this album was imported/updated CreatedAt time.Time `structs:"created_at" json:"createdAt"` // Oldest CreatedAt for all songs in this album UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` // Newest UpdatedAt for all songs in this album + + // BlurHash of the album cover, computed asynchronously from the served artwork. + BlurHash string `structs:"blur_hash" json:"blurHash,omitempty" hash:"ignore"` + BlurHashUpdatedAt *time.Time `structs:"blur_hash_updated_at" json:"-" hash:"ignore"` } func (a Album) CoverArtID() ArtworkID { return artworkIDFromAlbum(a) } +// ArtworkUpdatedAt is the album's artwork version: the newest row timestamp that can affect +// which cover image is served (scan updates, imports, agent-fetched external images). +func (a Album) ArtworkUpdatedAt() time.Time { + t := a.UpdatedAt + if a.ImportedAt.After(t) { + t = a.ImportedAt + } + if a.ExternalInfoUpdatedAt != nil && a.ExternalInfoUpdatedAt.After(t) { + t = *a.ExternalInfoUpdatedAt + } + return t +} + func (a Album) FullName() string { if conf.Server.Subsonic.AppendAlbumVersion && len(a.Tags[TagAlbumVersion]) > 0 { return fmt.Sprintf("%s (%s)", a.Name, a.Tags[TagAlbumVersion][0]) diff --git a/model/album_test.go b/model/album_test.go index 0f4c912cd..3caa359f9 100644 --- a/model/album_test.go +++ b/model/album_test.go @@ -2,6 +2,7 @@ package model_test import ( "encoding/json" + "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" @@ -50,3 +51,22 @@ var _ = Describe("Albums", func() { }) }) }) + +var _ = Describe("Album.ArtworkUpdatedAt", func() { + base := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + later := base.Add(24 * time.Hour) + latest := base.Add(48 * time.Hour) + + It("returns UpdatedAt when it is the newest", func() { + al := Album{UpdatedAt: later, ImportedAt: base} + Expect(al.ArtworkUpdatedAt()).To(Equal(later)) + }) + It("returns ImportedAt when it is the newest", func() { + al := Album{UpdatedAt: base, ImportedAt: later} + Expect(al.ArtworkUpdatedAt()).To(Equal(later)) + }) + It("returns ExternalInfoUpdatedAt when it is the newest", func() { + al := Album{UpdatedAt: base, ImportedAt: later, ExternalInfoUpdatedAt: &latest} + Expect(al.ArtworkUpdatedAt()).To(Equal(latest)) + }) +}) diff --git a/model/artist.go b/model/artist.go index f9c4bffd5..865355623 100644 --- a/model/artist.go +++ b/model/artist.go @@ -41,6 +41,9 @@ type Artist struct { CreatedAt *time.Time `structs:"created_at" json:"createdAt,omitempty"` UpdatedAt *time.Time `structs:"updated_at" json:"updatedAt,omitempty"` + + BlurHash string `structs:"blur_hash" json:"blurHash,omitempty"` + BlurHashUpdatedAt *time.Time `structs:"blur_hash_updated_at" json:"-"` } type ArtistStats struct { @@ -63,6 +66,19 @@ func (a Artist) CoverArtID() ArtworkID { return artworkIDFromArtist(a) } +// ArtworkUpdatedAt is the artist's artwork version; images often arrive via external agents, +// which bump ExternalInfoUpdatedAt rather than UpdatedAt. +func (a Artist) ArtworkUpdatedAt() time.Time { + var t time.Time + if a.UpdatedAt != nil { + t = *a.UpdatedAt + } + if a.ExternalInfoUpdatedAt != nil && a.ExternalInfoUpdatedAt.After(t) { + t = *a.ExternalInfoUpdatedAt + } + return t +} + func (a Artist) UploadedImagePath() string { return UploadedImagePath(consts.EntityArtist, a.UploadedImage) } diff --git a/model/artist_test.go b/model/artist_test.go index db897d3d5..135576e81 100644 --- a/model/artist_test.go +++ b/model/artist_test.go @@ -2,6 +2,7 @@ package model_test import ( "path/filepath" + "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" @@ -28,3 +29,18 @@ var _ = Describe("Artist", func() { }) }) }) + +var _ = Describe("Artist.ArtworkUpdatedAt", func() { + base := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + later := base.Add(24 * time.Hour) + + It("handles nil timestamps", func() { + Expect(model.Artist{}.ArtworkUpdatedAt()).To(Equal(time.Time{})) + }) + It("returns UpdatedAt when newest", func() { + Expect(model.Artist{UpdatedAt: &later, ExternalInfoUpdatedAt: &base}.ArtworkUpdatedAt()).To(Equal(later)) + }) + It("returns ExternalInfoUpdatedAt when newest", func() { + Expect(model.Artist{UpdatedAt: &base, ExternalInfoUpdatedAt: &later}.ArtworkUpdatedAt()).To(Equal(later)) + }) +}) diff --git a/model/playlist.go b/model/playlist.go index 40adb8d0a..60ca96a78 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -30,6 +30,9 @@ type Playlist struct { CreatedAt time.Time `structs:"created_at" json:"createdAt"` UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` + BlurHash string `structs:"blur_hash" json:"blurHash,omitempty"` + BlurHashUpdatedAt *time.Time `structs:"blur_hash_updated_at" json:"-"` + // SmartPlaylist attributes Rules *criteria.Criteria `structs:"rules" json:"rules"` EvaluatedAt *time.Time `structs:"evaluated_at" json:"evaluatedAt"` @@ -39,6 +42,10 @@ func (pls Playlist) IsSmartPlaylist() bool { return pls.Rules != nil && pls.Rules.Expression != nil } +func (pls Playlist) ArtworkUpdatedAt() time.Time { + return pls.UpdatedAt +} + func (pls Playlist) MediaFiles() MediaFiles { if len(pls.Tracks) == 0 { return nil diff --git a/model/playlist_test.go b/model/playlist_test.go index 9ed24f00f..5a51f7ef6 100644 --- a/model/playlist_test.go +++ b/model/playlist_test.go @@ -1,6 +1,8 @@ package model_test import ( + "time" + "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" @@ -44,3 +46,10 @@ var _ = Describe("Playlist", func() { }) }) }) + +var _ = Describe("Playlist.ArtworkUpdatedAt", func() { + It("returns UpdatedAt", func() { + now := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + Expect(model.Playlist{UpdatedAt: now}.ArtworkUpdatedAt()).To(Equal(now)) + }) +}) From f763ebff5bec8ee0f90ef60a46ad4b56785650cd Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 16 Jul 2026 00:04:26 -0400 Subject: [PATCH 03/46] feat(persistence): add UpdateBlurHash targeted update to album/artist/playlist repos --- .../20260716030719_add_artwork_blur_hash.sql | 7 +++-- model/album.go | 1 + model/artist.go | 1 + model/playlist.go | 1 + persistence/album_repository.go | 10 ++++++ persistence/album_repository_test.go | 31 +++++++++++++++++++ persistence/artist_repository.go | 10 ++++++ persistence/playlist_repository.go | 10 ++++++ tests/mock_album_repo.go | 11 +++++++ tests/mock_artist_repo.go | 11 +++++++ tests/mock_playlist_repo.go | 11 +++++++ 11 files changed, 101 insertions(+), 3 deletions(-) diff --git a/db/migrations/20260716030719_add_artwork_blur_hash.sql b/db/migrations/20260716030719_add_artwork_blur_hash.sql index a7b75dfc5..40c0c4014 100644 --- a/db/migrations/20260716030719_add_artwork_blur_hash.sql +++ b/db/migrations/20260716030719_add_artwork_blur_hash.sql @@ -1,9 +1,10 @@ -- +goose Up -alter table album add column blur_hash varchar; +-- blur_hash is not null default '' so NULLs never reach the Go string field; '' means "not computed". +alter table album add column blur_hash varchar not null default ''; alter table album add column blur_hash_updated_at datetime; -alter table artist add column blur_hash varchar; +alter table artist add column blur_hash varchar not null default ''; alter table artist add column blur_hash_updated_at datetime; -alter table playlist add column blur_hash varchar; +alter table playlist add column blur_hash varchar not null default ''; alter table playlist add column blur_hash_updated_at datetime; -- +goose Down diff --git a/model/album.go b/model/album.go index 6030e033e..9122276ff 100644 --- a/model/album.go +++ b/model/album.go @@ -156,6 +156,7 @@ type AlbumRepository interface { Exists(id string) (bool, error) Put(*Album) error UpdateExternalInfo(*Album) error + UpdateBlurHash(id string, blurHash string, artworkUpdatedAt time.Time) error Get(id string) (*Album, error) GetAll(...QueryOptions) (Albums, error) GetCursor(...QueryOptions) (AlbumCursor, error) diff --git a/model/artist.go b/model/artist.go index 865355623..b917023f8 100644 --- a/model/artist.go +++ b/model/artist.go @@ -103,6 +103,7 @@ type ArtistRepository interface { Exists(id string) (bool, error) Put(m *Artist, colsToUpdate ...string) error UpdateExternalInfo(a *Artist) error + UpdateBlurHash(id string, blurHash string, artworkUpdatedAt time.Time) error Get(id string) (*Artist, error) GetAll(options ...QueryOptions) (Artists, error) GetCursor(options ...QueryOptions) (ArtistCursor, error) diff --git a/model/playlist.go b/model/playlist.go index 60ca96a78..6c34b1a3d 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -142,6 +142,7 @@ type PlaylistRepository interface { GetAll(options ...QueryOptions) (Playlists, error) GetCursor(options ...QueryOptions) (PlaylistCursor, error) FindByPath(path string) (*Playlist, error) + UpdateBlurHash(id string, blurHash string, artworkUpdatedAt time.Time) error Delete(id string) error Tracks(playlistId string, refreshSmartPlaylist bool) PlaylistTrackRepository GetPlaylists(mediaFileId string) (Playlists, error) diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 6ebbd9202..35e5dba8f 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -213,6 +213,16 @@ func (r *albumRepository) Put(al *model.Album) error { return nil } +// UpdateBlurHash is a targeted update: a full-row put would race with the scanner. Deliberately +// a plain UPDATE with no insert fallback — updating a just-deleted row must be a silent no-op. +func (r *albumRepository) UpdateBlurHash(id, blurHash string, artworkUpdatedAt time.Time) error { + upd := Update(r.tableName).Where(Eq{"id": id}). + Set("blur_hash", blurHash). + Set("blur_hash_updated_at", artworkUpdatedAt) + _, err := r.executeSQL(upd) + return err +} + // TODO Move external metadata to a separated table func (r *albumRepository) UpdateExternalInfo(al *model.Album) error { _, err := r.put(al.ID, &dbAlbum{Album: al}, "description", "small_image_url", "medium_image_url", "large_image_url", "external_url", "external_info_updated_at") diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index 64ff0095e..bbb821ab8 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -899,3 +899,34 @@ func _p(id, name string, sortName ...string) model.Participant { } return p } + +var _ = Describe("AlbumRepository.UpdateBlurHash", func() { + var repo model.AlbumRepository + + BeforeEach(func() { + ctx := request.WithUser(GinkgoT().Context(), model.User{ID: "userid", UserName: "johndoe"}) + repo = NewAlbumRepository(ctx, GetDBXBuilder()) + DeferCleanup(func() { + _, err := GetDBXBuilder().NewQuery("update album set blur_hash = '', blur_hash_updated_at = null").Execute() + Expect(err).ToNot(HaveOccurred()) + }) + }) + + It("persists the hash and its artwork version snapshot", func() { + al, err := repo.Get("103") + Expect(err).ToNot(HaveOccurred()) + Expect(al.BlurHash).To(BeEmpty()) + + version := time.Date(2024, 5, 1, 10, 30, 0, 0, time.UTC) + Expect(repo.UpdateBlurHash(al.ID, "LKO2?U%2Tw=w]~RBVZRi};RPxuwH", version)).To(Succeed()) + + updated, err := repo.Get(al.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(updated.BlurHash).To(Equal("LKO2?U%2Tw=w]~RBVZRi};RPxuwH")) + Expect(updated.BlurHashUpdatedAt).ToNot(BeNil()) + // Round-trip through SQLite must preserve equality — the DTO layer compares with Equal. + Expect(updated.BlurHashUpdatedAt.Equal(version)).To(BeTrue()) + // The targeted update must not touch the row's own timestamps. + Expect(updated.UpdatedAt).To(Equal(al.UpdatedAt)) + }) +}) diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index b542dedb4..519e5cb94 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -232,6 +232,16 @@ func (r *artistRepository) Put(a *model.Artist, colsToUpdate ...string) error { return err } +// UpdateBlurHash is a targeted update: a full-row put would race with the scanner. Deliberately +// a plain UPDATE with no insert fallback — updating a just-deleted row must be a silent no-op. +func (r *artistRepository) UpdateBlurHash(id, blurHash string, artworkUpdatedAt time.Time) error { + upd := Update(r.tableName).Where(Eq{"id": id}). + Set("blur_hash", blurHash). + Set("blur_hash_updated_at", artworkUpdatedAt) + _, err := r.executeSQL(upd) + return err +} + func (r *artistRepository) UpdateExternalInfo(a *model.Artist) error { dba := &dbArtist{Artist: a} _, err := r.put(a.ID, dba, diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index e39f0bbd3..87af440c4 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -155,6 +155,16 @@ func (r *playlistRepository) GetWithTracks(id string, refreshSmartPlaylist, incl return pls, nil } +// UpdateBlurHash is a targeted update: a full-row put would race with playlist sync. Deliberately +// a plain UPDATE with no insert fallback — updating a just-deleted row must be a silent no-op. +func (r *playlistRepository) UpdateBlurHash(id, blurHash string, artworkUpdatedAt time.Time) error { + upd := Update(r.tableName).Where(Eq{"id": id}). + Set("blur_hash", blurHash). + Set("blur_hash_updated_at", artworkUpdatedAt) + _, err := r.executeSQL(upd) + return err +} + func (r *playlistRepository) FindByPath(path string) (*model.Playlist, error) { return r.findBy(Eq{"path": path}) } diff --git a/tests/mock_album_repo.go b/tests/mock_album_repo.go index 03dfed879..d68cf2904 100644 --- a/tests/mock_album_repo.go +++ b/tests/mock_album_repo.go @@ -29,6 +29,17 @@ func (m *MockAlbumRepo) SetError(err bool) { m.Err = err } +func (m *MockAlbumRepo) UpdateBlurHash(id, blurHash string, artworkUpdatedAt time.Time) error { + if m.Err { + return errors.New("unexpected error") + } + if al, ok := m.Data[id]; ok { + al.BlurHash = blurHash + al.BlurHashUpdatedAt = &artworkUpdatedAt + } + return nil +} + func (m *MockAlbumRepo) SetData(albums model.Albums) { m.Data = make(map[string]*model.Album, len(albums)) m.All = albums diff --git a/tests/mock_artist_repo.go b/tests/mock_artist_repo.go index e6ea7aea4..192b48ba5 100644 --- a/tests/mock_artist_repo.go +++ b/tests/mock_artist_repo.go @@ -32,6 +32,17 @@ func (m *MockArtistRepo) SetData(artists model.Artists) { } } +func (m *MockArtistRepo) UpdateBlurHash(id, blurHash string, artworkUpdatedAt time.Time) error { + if m.Err { + return errors.New("unexpected error") + } + if ar, ok := m.Data[id]; ok { + ar.BlurHash = blurHash + ar.BlurHashUpdatedAt = &artworkUpdatedAt + } + return nil +} + func (m *MockArtistRepo) Exists(id string) (bool, error) { if m.Err { return false, errors.New("Error!") diff --git a/tests/mock_playlist_repo.go b/tests/mock_playlist_repo.go index 8f8842c8e..6d6efcda8 100644 --- a/tests/mock_playlist_repo.go +++ b/tests/mock_playlist_repo.go @@ -42,6 +42,17 @@ func (m *MockPlaylistRepo) SetData(playlists model.Playlists) { } } +func (m *MockPlaylistRepo) UpdateBlurHash(id, blurHash string, artworkUpdatedAt time.Time) error { + if m.Err { + return errors.New("unexpected error") + } + if pl, ok := m.Data[id]; ok { + pl.BlurHash = blurHash + pl.BlurHashUpdatedAt = &artworkUpdatedAt + } + return nil +} + func (m *MockPlaylistRepo) GetAll(options ...model.QueryOptions) (model.Playlists, error) { if len(options) > 0 { m.Options = options[0] From 9f9019df0771b65f8269828a4506dd79e865b7e2 Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 16 Jul 2026 00:15:12 -0400 Subject: [PATCH 04/46] feat(artwork): compute and persist blurhashes asynchronously on artwork serve --- core/artwork/artwork.go | 16 +- core/artwork/blurhash_updater.go | 185 ++++++++++++++++++ .../artwork/blurhash_updater_internal_test.go | 72 +++++++ 3 files changed, 268 insertions(+), 5 deletions(-) create mode 100644 core/artwork/blurhash_updater.go create mode 100644 core/artwork/blurhash_updater_internal_test.go 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)) + } + }) + }) +}) From 585ac0aab31522b34789dca094d7b589e59caca0 Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 16 Jul 2026 00:21:19 -0400 Subject: [PATCH 05/46] feat(jellyfin): emit stored blurhashes with version-seeded fake fallback --- server/jellyfin/dto/blurhash.go | 16 +++++++++++++- server/jellyfin/dto/blurhash_test.go | 27 ++++++++++++++++++++++++ server/jellyfin/dto/mappers.go | 6 +++--- server/jellyfin/dto/mappers_test.go | 31 ++++++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 4 deletions(-) diff --git a/server/jellyfin/dto/blurhash.go b/server/jellyfin/dto/blurhash.go index aaf6ff2af..cc846bb81 100644 --- a/server/jellyfin/dto/blurhash.go +++ b/server/jellyfin/dto/blurhash.go @@ -1,6 +1,10 @@ package dto -import "hash/fnv" +import ( + "fmt" + "hash/fnv" + "time" +) // base83Alphabet is the blurhash spec's base83 encoding alphabet; order is part of the spec. const base83Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~" @@ -34,3 +38,13 @@ func blurHash(seed string) string { dc := (r << 16) | (g << 8) | b return "00" + base83(dc, 4) } + +// primaryBlurHash returns the stored blurhash when it was computed from the entity's current +// artwork version; otherwise a fake seeded by id+version, so the value still rotates on any +// artwork change (Finamp keys its cover caches by this value; tags never reach its image URLs). +func primaryBlurHash(stored string, storedAt *time.Time, id string, version time.Time) string { + if stored != "" && storedAt != nil && storedAt.Equal(version) { + return stored + } + return blurHash(fmt.Sprintf("%s-%x", id, version.UnixMilli())) +} diff --git a/server/jellyfin/dto/blurhash_test.go b/server/jellyfin/dto/blurhash_test.go index a6e36131d..78cd242f8 100644 --- a/server/jellyfin/dto/blurhash_test.go +++ b/server/jellyfin/dto/blurhash_test.go @@ -2,6 +2,7 @@ package dto import ( "strings" + "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -25,3 +26,29 @@ var _ = Describe("blurHash", func() { Expect(blurHash("cover-tag-1")).ToNot(Equal(blurHash("cover-tag-2"))) }) }) + +var _ = Describe("primaryBlurHash", func() { + version := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + + It("returns the stored hash when it matches the current artwork version", func() { + Expect(primaryBlurHash("LEHV6nWB2yk8", &version, "id-1", version)).To(Equal("LEHV6nWB2yk8")) + }) + + It("falls back to a fake when there is no stored hash", func() { + h := primaryBlurHash("", nil, "id-1", version) + Expect(h).To(HaveLen(6)) + }) + + It("falls back to a fake when the stored hash is stale", func() { + stale := version.Add(-time.Hour) + h := primaryBlurHash("LEHV6nWB2yk8", &stale, "id-1", version) + Expect(h).To(HaveLen(6)) + Expect(h).ToNot(Equal("LEHV6nWB2yk8")) + }) + + It("rotates the fake when the artwork version moves", func() { + h1 := primaryBlurHash("", nil, "id-1", version) + h2 := primaryBlurHash("", nil, "id-1", version.Add(time.Hour)) + Expect(h1).ToNot(Equal(h2)) + }) +}) diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index c65b8933d..e284ad0de 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -201,7 +201,7 @@ func AlbumToBaseItem(al model.Album) BaseItemDto { RunTimeTicks: TicksFromSeconds(al.Duration), DateCreated: jellyfinDate(&al.CreatedAt), ImageTags: map[string]string{"Primary": al.ID}, - ImageBlurHashes: map[string]map[string]string{"Primary": {al.ID: blurHash(al.ID)}}, + ImageBlurHashes: map[string]map[string]string{"Primary": {al.ID: primaryBlurHash(al.BlurHash, al.BlurHashUpdatedAt, al.ID, al.ArtworkUpdatedAt())}}, BackdropImageTags: []string{}, UserData: UserData(al.Annotations, al.ID), } @@ -231,7 +231,7 @@ func ArtistToBaseItem(ar model.Artist) BaseItemDto { SongCount: new(ar.SongCount), DateCreated: jellyfinDate(ar.CreatedAt), ImageTags: map[string]string{"Primary": ar.ID}, - ImageBlurHashes: map[string]map[string]string{"Primary": {ar.ID: blurHash(ar.ID)}}, + ImageBlurHashes: map[string]map[string]string{"Primary": {ar.ID: primaryBlurHash(ar.BlurHash, ar.BlurHashUpdatedAt, ar.ID, ar.ArtworkUpdatedAt())}}, BackdropImageTags: []string{}, UserData: UserData(ar.Annotations, ar.ID), } @@ -264,7 +264,7 @@ func PlaylistToBaseItem(p model.Playlist) BaseItemDto { ChildCount: new(p.SongCount), RunTimeTicks: TicksFromSeconds(p.Duration), ImageTags: map[string]string{"Primary": tag}, - ImageBlurHashes: map[string]map[string]string{"Primary": {tag: blurHash(tag)}}, + ImageBlurHashes: map[string]map[string]string{"Primary": {tag: primaryBlurHash(p.BlurHash, p.BlurHashUpdatedAt, p.ID, p.ArtworkUpdatedAt())}}, BackdropImageTags: []string{}, UserData: UserData(p.Annotations, p.ID), } diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index e72efd617..fbd3b16bd 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -390,3 +390,34 @@ var _ = Describe("LyricDtoFromLyrics", func() { Expect(c.Start).To(Equal(int64(10_000_000))) }) }) + +var _ = Describe("stored blurhashes", func() { + version := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + + It("emits the stored album blurhash when fresh, and a rotating fake when stale", func() { + al := model.Album{ID: "al-1", Name: "A", UpdatedAt: version, ImportedAt: version, + BlurHash: "LEHV6nWB2yk8", BlurHashUpdatedAt: &version} + Expect(AlbumToBaseItem(al).ImageBlurHashes["Primary"]["al-1"]).To(Equal("LEHV6nWB2yk8")) + + al.UpdatedAt = version.Add(time.Hour) // artwork version moved; stored hash is now stale + fake := AlbumToBaseItem(al).ImageBlurHashes["Primary"]["al-1"] + Expect(fake).To(HaveLen(6)) + + al.UpdatedAt = version.Add(2 * time.Hour) + Expect(AlbumToBaseItem(al).ImageBlurHashes["Primary"]["al-1"]).ToNot(Equal(fake)) + }) + + It("emits the stored artist blurhash when fresh", func() { + ar := model.Artist{ID: "ar-1", Name: "B", UpdatedAt: &version, + BlurHash: "LEHV6nWB2yk8", BlurHashUpdatedAt: &version} + Expect(ArtistToBaseItem(ar).ImageBlurHashes["Primary"]["ar-1"]).To(Equal("LEHV6nWB2yk8")) + }) + + It("emits the stored playlist blurhash when fresh, keyed by the versioned tag", func() { + p := model.Playlist{ID: "pl-1", Name: "P", UpdatedAt: version, + BlurHash: "LEHV6nWB2yk8", BlurHashUpdatedAt: &version} + item := PlaylistToBaseItem(p) + tag := item.ImageTags["Primary"] + Expect(item.ImageBlurHashes["Primary"][tag]).To(Equal("LEHV6nWB2yk8")) + }) +}) From 76e1fba06763e3bedcf970b070c749d53d6ec426 Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 16 Jul 2026 00:25:44 -0400 Subject: [PATCH 06/46] test(artwork): e2e coverage for async blurhash persistence --- core/artwork/e2e/blurhash_test.go | 53 +++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 core/artwork/e2e/blurhash_test.go diff --git a/core/artwork/e2e/blurhash_test.go b/core/artwork/e2e/blurhash_test.go new file mode 100644 index 000000000..9d69ed27c --- /dev/null +++ b/core/artwork/e2e/blurhash_test.go @@ -0,0 +1,53 @@ +package artworke2e_test + +import ( + "testing/fstest" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("BlurHash", func() { + BeforeEach(func() { + setupHarness() + }) + + It("persists a real blurhash after album artwork is served", func() { + setLayout(fstest.MapFS{ + "Artist/Album/01 - Song.mp3": trackFile(1, "Song"), + "Artist/Album/cover.png": realPNG("blurhash-album"), + }) + scan() + al := firstAlbum() + Expect(al.BlurHash).To(BeEmpty()) + + // Serving the artwork enqueues the async blurhash computation. + readArtwork(al.CoverArtID()) + + Eventually(func(g Gomega) { + updated, err := ds.Album(ctx).Get(al.ID) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(len(updated.BlurHash)).To(BeNumerically(">", 6)) + g.Expect(updated.BlurHashUpdatedAt).ToNot(BeNil()) + // The snapshot must match the artwork version, or the DTO layer would treat it as stale. + g.Expect(updated.BlurHashUpdatedAt.Equal(updated.ArtworkUpdatedAt())).To(BeTrue()) + }, "10s", "100ms").Should(Succeed()) + }) + + It("does not persist a blurhash when the served image cannot be decoded", func() { + setLayout(fstest.MapFS{ + "Artist/Album/01 - Song.mp3": trackFile(1, "Song"), + "Artist/Album/cover.png": imageFile("not-a-real-image"), + }) + scan() + al := firstAlbum() + + readArtwork(al.CoverArtID()) + + Consistently(func(g Gomega) { + updated, err := ds.Album(ctx).Get(al.ID) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(updated.BlurHash).To(BeEmpty()) + }, "600ms", "100ms").Should(Succeed()) + }) +}) From 0b365a00900882e63b112ae3c474626bbbd99669 Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 16 Jul 2026 00:35:17 -0400 Subject: [PATCH 07/46] refactor(artwork): simplify blurhash cleanup review findings - detect placeholder artwork via the reader's source path instead of comparing against precomputed placeholder hashes (fragile fingerprint, dead artist-placeholder branch) - collapse the three identical UpdateBlurHash repo methods into a shared sqlRepository.updateBlurHash helper --- core/artwork/blurhash_updater.go | 41 ++++--------------- .../artwork/blurhash_updater_internal_test.go | 10 ----- persistence/album_repository.go | 8 +--- persistence/artist_repository.go | 8 +--- persistence/playlist_repository.go | 8 +--- persistence/sql_base_repository.go | 10 +++++ 6 files changed, 20 insertions(+), 65 deletions(-) diff --git a/core/artwork/blurhash_updater.go b/core/artwork/blurhash_updater.go index e031d82a4..90947c2b1 100644 --- a/core/artwork/blurhash_updater.go +++ b/core/artwork/blurhash_updater.go @@ -11,7 +11,6 @@ import ( "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 @@ -127,26 +126,23 @@ func (u *blurHashUpdater) computeFromArtwork(ctx context.Context, artID model.Ar 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) + // 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) if err != nil { return "", err } defer r.Close() + if path == consts.PlaceholderAlbumArt || path == consts.PlaceholderArtistArt { + return "", nil + } 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 + return blurhash.Encode(img, x, y) } func (u *blurHashUpdater) persist(ctx context.Context, artID model.ArtworkID, hash string, version time.Time) error { @@ -160,26 +156,3 @@ func (u *blurHashUpdater) persist(ctx context.Context, artID model.ArtworkID, ha } 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 index 36c06686e..1dd22f308 100644 --- a/core/artwork/blurhash_updater_internal_test.go +++ b/core/artwork/blurhash_updater_internal_test.go @@ -59,14 +59,4 @@ var _ = Describe("blurHashUpdater", func() { 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)) - } - }) - }) }) diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 35e5dba8f..cd5e3f1c0 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -213,14 +213,8 @@ func (r *albumRepository) Put(al *model.Album) error { return nil } -// UpdateBlurHash is a targeted update: a full-row put would race with the scanner. Deliberately -// a plain UPDATE with no insert fallback — updating a just-deleted row must be a silent no-op. func (r *albumRepository) UpdateBlurHash(id, blurHash string, artworkUpdatedAt time.Time) error { - upd := Update(r.tableName).Where(Eq{"id": id}). - Set("blur_hash", blurHash). - Set("blur_hash_updated_at", artworkUpdatedAt) - _, err := r.executeSQL(upd) - return err + return r.updateBlurHash(id, blurHash, artworkUpdatedAt) } // TODO Move external metadata to a separated table diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index 519e5cb94..1308a1d5c 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -232,14 +232,8 @@ func (r *artistRepository) Put(a *model.Artist, colsToUpdate ...string) error { return err } -// UpdateBlurHash is a targeted update: a full-row put would race with the scanner. Deliberately -// a plain UPDATE with no insert fallback — updating a just-deleted row must be a silent no-op. func (r *artistRepository) UpdateBlurHash(id, blurHash string, artworkUpdatedAt time.Time) error { - upd := Update(r.tableName).Where(Eq{"id": id}). - Set("blur_hash", blurHash). - Set("blur_hash_updated_at", artworkUpdatedAt) - _, err := r.executeSQL(upd) - return err + return r.updateBlurHash(id, blurHash, artworkUpdatedAt) } func (r *artistRepository) UpdateExternalInfo(a *model.Artist) error { diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 87af440c4..e5e97043c 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -155,14 +155,8 @@ func (r *playlistRepository) GetWithTracks(id string, refreshSmartPlaylist, incl return pls, nil } -// UpdateBlurHash is a targeted update: a full-row put would race with playlist sync. Deliberately -// a plain UPDATE with no insert fallback — updating a just-deleted row must be a silent no-op. func (r *playlistRepository) UpdateBlurHash(id, blurHash string, artworkUpdatedAt time.Time) error { - upd := Update(r.tableName).Where(Eq{"id": id}). - Set("blur_hash", blurHash). - Set("blur_hash_updated_at", artworkUpdatedAt) - _, err := r.executeSQL(upd) - return err + return r.updateBlurHash(id, blurHash, artworkUpdatedAt) } func (r *playlistRepository) FindByPath(path string) (*model.Playlist, error) { diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index d0cbb2946..be6fcfb74 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -293,6 +293,16 @@ func (r sqlRepository) resetSeededRandom(options []model.QueryOptions) { } } +// updateBlurHash is a targeted update: a full-row put would race with the scanner. Deliberately +// a plain UPDATE with no insert fallback — updating a just-deleted row must be a silent no-op. +func (r sqlRepository) updateBlurHash(id, blurHash string, artworkUpdatedAt time.Time) error { + upd := Update(r.tableName).Where(Eq{"id": id}). + Set("blur_hash", blurHash). + Set("blur_hash_updated_at", artworkUpdatedAt) + _, err := r.executeSQL(upd) + return err +} + func (r sqlRepository) executeSQL(sq Sqlizer) (int64, error) { query, args, err := r.toSQL(sq) if err != nil { From e100c48102ee97ce46644b0e87a41b28e1b5a67e Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 16 Jul 2026 08:21:57 -0400 Subject: [PATCH 08/46] fix(artwork): address blurhash review findings - force recompute on image-cache miss, so in-place cover/sidecar swaps and agent image updates refresh the stored hash even when no entity row moved - exclude external_info_updated_at from the artwork version: agent TTL refreshes bump it with an unchanged image, churning Finamp's cover cache - lazy-start the worker goroutine and bound each computation with a 30s timeout; remember no-result entities per version to avoid re-decoding placeholders on every serve - error (instead of silently skipping) on unknown artwork kinds in persist --- core/artwork/artwork.go | 5 +- core/artwork/blurhash_updater.go | 88 +++++++++++++------ .../artwork/blurhash_updater_internal_test.go | 44 +++++++--- model/album.go | 8 +- model/album_test.go | 4 +- model/artist.go | 15 ++-- model/artist_test.go | 6 +- 7 files changed, 112 insertions(+), 58 deletions(-) diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go index 51fefcc18..ee1a6807b 100644 --- a/core/artwork/artwork.go +++ b/core/artwork/artwork.go @@ -74,7 +74,10 @@ 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) + // A cache miss means the image is new or changed, even when no entity row moved (e.g. an + // in-place cover.jpg swap) — force a recompute so the stored blurhash follows the image. + force := !r.Cached && !a.cache.Disabled(ctx) + a.blurHashes.Enqueue(artID, force) } return r, artReader.LastUpdated(), nil } diff --git a/core/artwork/blurhash_updater.go b/core/artwork/blurhash_updater.go index 90947c2b1..fee2d388d 100644 --- a/core/artwork/blurhash_updater.go +++ b/core/artwork/blurhash_updater.go @@ -2,6 +2,7 @@ package artwork import ( "context" + "fmt" "image" "sync" "time" @@ -17,32 +18,36 @@ import ( // 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{} + a *artwork + mutex sync.Mutex + buffer map[model.ArtworkID]bool // value: force recompute (image-cache miss) + noResult map[model.ArtworkID]time.Time + wake chan struct{} + start sync.Once } func newBlurHashUpdater(a *artwork) *blurHashUpdater { - u := &blurHashUpdater{ - a: a, - buffer: make(map[model.ArtworkID]struct{}), - wake: make(chan struct{}, 1), + return &blurHashUpdater{ + a: a, + buffer: make(map[model.ArtworkID]bool), + noResult: make(map[model.ArtworkID]time.Time), + 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) { +func (u *blurHashUpdater) Enqueue(artID model.ArtworkID, force bool) { switch artID.Kind { case model.KindAlbumArtwork, model.KindArtistArtwork, model.KindPlaylistArtwork: default: return } + u.start.Do(func() { + // Playlist artwork readers require a user in the context. Like the cacheWarmer, the worker + // lives for the rest of the process; lazy-starting keeps idle Artwork instances goroutine-free. + go u.run(request.WithUser(context.TODO(), model.User{IsAdmin: true})) + }) u.mutex.Lock() - u.buffer[artID] = struct{}{} + u.buffer[artID] = u.buffer[artID] || force u.mutex.Unlock() select { case u.wake <- struct{}{}: @@ -53,48 +58,81 @@ func (u *blurHashUpdater) Enqueue(artID model.ArtworkID) { func (u *blurHashUpdater) run(ctx context.Context) { for range u.wake { for { - artID, ok := u.next() + artID, force, ok := u.next() if !ok { break } - u.process(ctx, artID) + u.process(ctx, artID, force) } } } -func (u *blurHashUpdater) next() (model.ArtworkID, bool) { +func (u *blurHashUpdater) next() (model.ArtworkID, bool, bool) { u.mutex.Lock() defer u.mutex.Unlock() - for artID := range u.buffer { + for artID, force := range u.buffer { delete(u.buffer, artID) - return artID, true + return artID, force, true } - return model.ArtworkID{}, false + return model.ArtworkID{}, false, false } -func (u *blurHashUpdater) process(ctx context.Context, artID model.ArtworkID) { +// processTimeout bounds one computation: readers can call external agents, and a hung call must +// not stall the worker forever. +const processTimeout = 30 * time.Second + +func (u *blurHashUpdater) process(ctx context.Context, artID model.ArtworkID, force bool) { // 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) } }() + ctx, cancel := context.WithTimeout(ctx, processTimeout) + defer cancel() 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 + if !force { + if stored != "" && storedAt != nil && storedAt.Equal(version) { + return + } + if last, ok := u.lastNoResult(artID); ok && last.Equal(version) { + return + } } hash, err := u.computeFromArtwork(ctx, artID) if err != nil || hash == "" { - log.Trace(ctx, "BlurHash: skipping", "artID", artID, err) + log.Trace(ctx, "BlurHash: nothing to persist", "artID", artID, err) + u.setNoResult(artID, version) return } if err := u.persist(ctx, artID, hash, version); err != nil { log.Warn(ctx, "BlurHash: error persisting", "artID", artID, err) + return } + u.clearNoResult(artID) +} + +func (u *blurHashUpdater) lastNoResult(artID model.ArtworkID) (time.Time, bool) { + u.mutex.Lock() + defer u.mutex.Unlock() + t, ok := u.noResult[artID] + return t, ok +} + +func (u *blurHashUpdater) setNoResult(artID model.ArtworkID, version time.Time) { + u.mutex.Lock() + defer u.mutex.Unlock() + u.noResult[artID] = version +} + +func (u *blurHashUpdater) clearNoResult(artID model.ArtworkID) { + u.mutex.Lock() + defer u.mutex.Unlock() + delete(u.noResult, artID) } func (u *blurHashUpdater) loadState(ctx context.Context, artID model.ArtworkID) (string, *time.Time, time.Time, error) { @@ -154,5 +192,5 @@ func (u *blurHashUpdater) persist(ctx context.Context, artID model.ArtworkID, ha case model.KindPlaylistArtwork: return u.a.ds.Playlist(ctx).UpdateBlurHash(artID.ID, hash, version) } - return nil + return fmt.Errorf("blurhash: no persister for artwork kind %q", artID.Kind) } diff --git a/core/artwork/blurhash_updater_internal_test.go b/core/artwork/blurhash_updater_internal_test.go index 1dd22f308..70022bd51 100644 --- a/core/artwork/blurhash_updater_internal_test.go +++ b/core/artwork/blurhash_updater_internal_test.go @@ -17,46 +17,64 @@ var _ = Describe("blurHashUpdater", 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), + a: &artwork{ds: ds}, + buffer: make(map[model.ArtworkID]bool), + noResult: make(map[model.ArtworkID]time.Time), + wake: make(chan struct{}, 1), } }) Describe("Enqueue", func() { - It("accepts album, artist and playlist artwork and dedups", func() { + It("accepts album, artist and playlist artwork and dedups, keeping the force flag sticky", func() { id := model.Album{ID: "al-1"}.CoverArtID() - u.Enqueue(id) - u.Enqueue(id) - u.Enqueue(model.Artist{ID: "ar-1"}.CoverArtID()) + u.Enqueue(id, true) + u.Enqueue(id, false) + u.Enqueue(model.Artist{ID: "ar-1"}.CoverArtID(), false) Expect(u.buffer).To(HaveLen(2)) + Expect(u.buffer[id]).To(BeTrue()) }) 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"}) + u.Enqueue(model.ArtworkID{Kind: model.KindMediaFileArtwork, ID: "mf-1"}, false) + u.Enqueue(model.ArtworkID{Kind: model.KindRadioArtwork, ID: "ra-1"}, true) Expect(u.buffer).To(BeEmpty()) }) }) Describe("process", func() { + var version time.Time + + BeforeEach(func() { + version = time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + }) + 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()) + u.process(GinkgoT().Context(), al.CoverArtID(), false) stored, err := ds.Album(GinkgoT().Context()).Get("al-1") Expect(err).ToNot(HaveOccurred()) Expect(stored.BlurHash).To(Equal("LEHV6nWB2yk8")) }) + It("skips entities that previously yielded no result for the same artwork version", func() { + al := model.Album{ID: "al-1", UpdatedAt: version} + repo := tests.CreateMockAlbumRepo() + repo.SetData(model.Albums{al}) + ds.MockedAlbum = repo + u.setNoResult(al.CoverArtID(), al.ArtworkUpdatedAt()) + + u.process(GinkgoT().Context(), al.CoverArtID(), false) + stored, _ := ds.Album(GinkgoT().Context()).Get("al-1") + Expect(stored.BlurHash).To(BeEmpty()) + }) + 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()) + Expect(func() { u.process(GinkgoT().Context(), model.Album{ID: "missing"}.CoverArtID(), false) }).ToNot(Panic()) }) }) }) diff --git a/model/album.go b/model/album.go index 9122276ff..4e8dfae51 100644 --- a/model/album.go +++ b/model/album.go @@ -77,16 +77,14 @@ func (a Album) CoverArtID() ArtworkID { return artworkIDFromAlbum(a) } -// ArtworkUpdatedAt is the album's artwork version: the newest row timestamp that can affect -// which cover image is served (scan updates, imports, agent-fetched external images). +// ArtworkUpdatedAt is the album's artwork version. ExternalInfoUpdatedAt is deliberately excluded: +// it bumps on every agent TTL refresh even when the image is unchanged, and actual image changes +// are caught by the image-cache-miss recompute instead. func (a Album) ArtworkUpdatedAt() time.Time { t := a.UpdatedAt if a.ImportedAt.After(t) { t = a.ImportedAt } - if a.ExternalInfoUpdatedAt != nil && a.ExternalInfoUpdatedAt.After(t) { - t = *a.ExternalInfoUpdatedAt - } return t } diff --git a/model/album_test.go b/model/album_test.go index 3caa359f9..9dcf2353e 100644 --- a/model/album_test.go +++ b/model/album_test.go @@ -65,8 +65,8 @@ var _ = Describe("Album.ArtworkUpdatedAt", func() { al := Album{UpdatedAt: base, ImportedAt: later} Expect(al.ArtworkUpdatedAt()).To(Equal(later)) }) - It("returns ExternalInfoUpdatedAt when it is the newest", func() { + It("ignores ExternalInfoUpdatedAt (agent TTL refreshes bump it without an image change)", func() { al := Album{UpdatedAt: base, ImportedAt: later, ExternalInfoUpdatedAt: &latest} - Expect(al.ArtworkUpdatedAt()).To(Equal(latest)) + Expect(al.ArtworkUpdatedAt()).To(Equal(later)) }) }) diff --git a/model/artist.go b/model/artist.go index b917023f8..bbb27c3a8 100644 --- a/model/artist.go +++ b/model/artist.go @@ -66,17 +66,14 @@ func (a Artist) CoverArtID() ArtworkID { return artworkIDFromArtist(a) } -// ArtworkUpdatedAt is the artist's artwork version; images often arrive via external agents, -// which bump ExternalInfoUpdatedAt rather than UpdatedAt. +// ArtworkUpdatedAt is the artist's artwork version. ExternalInfoUpdatedAt is deliberately +// excluded: it bumps on every agent TTL refresh even when the image is unchanged, and actual +// image changes are caught by the image-cache-miss recompute instead. func (a Artist) ArtworkUpdatedAt() time.Time { - var t time.Time - if a.UpdatedAt != nil { - t = *a.UpdatedAt + if a.UpdatedAt == nil { + return time.Time{} } - if a.ExternalInfoUpdatedAt != nil && a.ExternalInfoUpdatedAt.After(t) { - t = *a.ExternalInfoUpdatedAt - } - return t + return *a.UpdatedAt } func (a Artist) UploadedImagePath() string { diff --git a/model/artist_test.go b/model/artist_test.go index 135576e81..2d78d8ab3 100644 --- a/model/artist_test.go +++ b/model/artist_test.go @@ -37,10 +37,10 @@ var _ = Describe("Artist.ArtworkUpdatedAt", func() { It("handles nil timestamps", func() { Expect(model.Artist{}.ArtworkUpdatedAt()).To(Equal(time.Time{})) }) - It("returns UpdatedAt when newest", func() { + It("returns UpdatedAt", func() { Expect(model.Artist{UpdatedAt: &later, ExternalInfoUpdatedAt: &base}.ArtworkUpdatedAt()).To(Equal(later)) }) - It("returns ExternalInfoUpdatedAt when newest", func() { - Expect(model.Artist{UpdatedAt: &base, ExternalInfoUpdatedAt: &later}.ArtworkUpdatedAt()).To(Equal(later)) + It("ignores ExternalInfoUpdatedAt (agent TTL refreshes bump it without an image change)", func() { + Expect(model.Artist{UpdatedAt: &base, ExternalInfoUpdatedAt: &later}.ArtworkUpdatedAt()).To(Equal(base)) }) }) From 9ea4e22ea016a2ba2d8829035da196b6ef687f7c Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 17 Jul 2026 11:13:59 -0400 Subject: [PATCH 09/46] fix(artwork): address PR review bot feedback - don't record noResult for timed-out/cancelled computations (transient failures must not suppress retries for the same artwork version) - bound the noResult negative cache at 25k entries - use context.Background for the worker's root context - add hash:"ignore" to Artist/Playlist blurhash fields for consistency with Album (inert today; neither struct is hashed) --- core/artwork/blurhash_updater.go | 14 ++++++++++++-- model/artist.go | 4 ++-- model/playlist.go | 4 ++-- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/core/artwork/blurhash_updater.go b/core/artwork/blurhash_updater.go index fee2d388d..9f93b628d 100644 --- a/core/artwork/blurhash_updater.go +++ b/core/artwork/blurhash_updater.go @@ -44,7 +44,7 @@ func (u *blurHashUpdater) Enqueue(artID model.ArtworkID, force bool) { u.start.Do(func() { // Playlist artwork readers require a user in the context. Like the cacheWarmer, the worker // lives for the rest of the process; lazy-starting keeps idle Artwork instances goroutine-free. - go u.run(request.WithUser(context.TODO(), model.User{IsAdmin: true})) + go u.run(request.WithUser(context.Background(), model.User{IsAdmin: true})) }) u.mutex.Lock() u.buffer[artID] = u.buffer[artID] || force @@ -106,7 +106,10 @@ func (u *blurHashUpdater) process(ctx context.Context, artID model.ArtworkID, fo hash, err := u.computeFromArtwork(ctx, artID) if err != nil || hash == "" { log.Trace(ctx, "BlurHash: nothing to persist", "artID", artID, err) - u.setNoResult(artID, version) + // A timed-out/cancelled attempt is transient — don't suppress future retries for it. + if ctx.Err() == nil { + u.setNoResult(artID, version) + } return } if err := u.persist(ctx, artID, hash, version); err != nil { @@ -123,9 +126,16 @@ func (u *blurHashUpdater) lastNoResult(artID model.ArtworkID) (time.Time, bool) return t, ok } +// maxNoResultEntries bounds the negative cache; entries only accumulate for artwork-less entities, +// so a wholesale reset just costs those entities one extra verification pass each. +const maxNoResultEntries = 25_000 + func (u *blurHashUpdater) setNoResult(artID model.ArtworkID, version time.Time) { u.mutex.Lock() defer u.mutex.Unlock() + if len(u.noResult) >= maxNoResultEntries { + clear(u.noResult) + } u.noResult[artID] = version } diff --git a/model/artist.go b/model/artist.go index bbb27c3a8..de7cd53be 100644 --- a/model/artist.go +++ b/model/artist.go @@ -42,8 +42,8 @@ type Artist struct { CreatedAt *time.Time `structs:"created_at" json:"createdAt,omitempty"` UpdatedAt *time.Time `structs:"updated_at" json:"updatedAt,omitempty"` - BlurHash string `structs:"blur_hash" json:"blurHash,omitempty"` - BlurHashUpdatedAt *time.Time `structs:"blur_hash_updated_at" json:"-"` + BlurHash string `structs:"blur_hash" json:"blurHash,omitempty" hash:"ignore"` + BlurHashUpdatedAt *time.Time `structs:"blur_hash_updated_at" json:"-" hash:"ignore"` } type ArtistStats struct { diff --git a/model/playlist.go b/model/playlist.go index fde7b2ae4..8970e27e2 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -31,8 +31,8 @@ type Playlist struct { CreatedAt time.Time `structs:"created_at" json:"createdAt"` UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` - BlurHash string `structs:"blur_hash" json:"blurHash,omitempty"` - BlurHashUpdatedAt *time.Time `structs:"blur_hash_updated_at" json:"-"` + BlurHash string `structs:"blur_hash" json:"blurHash,omitempty" hash:"ignore"` + BlurHashUpdatedAt *time.Time `structs:"blur_hash_updated_at" json:"-" hash:"ignore"` // SmartPlaylist attributes Rules *criteria.Criteria `structs:"rules" json:"rules"` From 696399dab774d3b936be5b683a2544c446a96e9d Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 17 Jul 2026 11:20:41 -0400 Subject: [PATCH 10/46] fix(artwork): don't memoize transient blurhash failures; track image freshness with cache disabled Only ErrUnavailable (definitively no artwork) is negative-cached; timeouts and storage/agent hiccups retry on a later serve. Enqueue now carries the reader's LastUpdated so file swaps are detected even when the image cache is disabled (where every serve reads the source and miss-forcing is off). --- core/artwork/artwork.go | 3 +- core/artwork/blurhash/blurhash_bench_test.go | 41 +++++++++++++ core/artwork/blurhash_updater.go | 59 +++++++++++++------ .../artwork/blurhash_updater_internal_test.go | 47 +++++++++++---- 4 files changed, 118 insertions(+), 32 deletions(-) create mode 100644 core/artwork/blurhash/blurhash_bench_test.go diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go index ee1a6807b..3b403ae6c 100644 --- a/core/artwork/artwork.go +++ b/core/artwork/artwork.go @@ -76,8 +76,9 @@ func (a *artwork) Get(ctx context.Context, artID model.ArtworkID, size int, squa if a.blurHashes != nil { // A cache miss means the image is new or changed, even when no entity row moved (e.g. an // in-place cover.jpg swap) — force a recompute so the stored blurhash follows the image. + // The reader's LastUpdated covers the same case when the image cache is disabled. force := !r.Cached && !a.cache.Disabled(ctx) - a.blurHashes.Enqueue(artID, force) + a.blurHashes.Enqueue(artID, artReader.LastUpdated(), force) } return r, artReader.LastUpdated(), nil } diff --git a/core/artwork/blurhash/blurhash_bench_test.go b/core/artwork/blurhash/blurhash_bench_test.go new file mode 100644 index 000000000..43b593eb5 --- /dev/null +++ b/core/artwork/blurhash/blurhash_bench_test.go @@ -0,0 +1,41 @@ +package blurhash_test + +import ( + "fmt" + "image" + "image/color" + "testing" + + "github.com/navidrome/navidrome/core/artwork/blurhash" +) + +// benchImage builds a deterministic gradient so runs are comparable across revisions. +func benchImage(size int) image.Image { + img := image.NewNRGBA(image.Rect(0, 0, size, size)) + for y := 0; y < size; y++ { + for x := 0; x < size; x++ { + img.SetNRGBA(x, y, color.NRGBA{ + R: uint8(255 * x / size), + G: uint8(255 * y / size), + B: uint8((x + y) * 255 / (2 * size)), + A: 255, + }) + } + } + return img +} + +func BenchmarkEncode(b *testing.B) { + for _, size := range []int{100, 300, 600, 900, 1200, 1500} { + img := benchImage(size) + x, y := blurhash.Components(size, size) + b.Run(fmt.Sprintf("%dx%d", size, size), func(b *testing.B) { + b.ReportAllocs() + for range b.N { + if _, err := blurhash.Encode(img, x, y); err != nil { + b.Fatal(err) + } + } + }) + } +} diff --git a/core/artwork/blurhash_updater.go b/core/artwork/blurhash_updater.go index 9f93b628d..735256baa 100644 --- a/core/artwork/blurhash_updater.go +++ b/core/artwork/blurhash_updater.go @@ -2,6 +2,7 @@ package artwork import ( "context" + "errors" "fmt" "image" "sync" @@ -17,10 +18,17 @@ import ( // 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. +// enqueueRequest carries the staleness signals seen at serve time: force (image-cache miss) and +// the reader's LastUpdated, which tracks file mtimes that no entity row timestamp reflects. +type enqueueRequest struct { + force bool + imageUpdatedAt time.Time +} + type blurHashUpdater struct { a *artwork mutex sync.Mutex - buffer map[model.ArtworkID]bool // value: force recompute (image-cache miss) + buffer map[model.ArtworkID]enqueueRequest noResult map[model.ArtworkID]time.Time wake chan struct{} start sync.Once @@ -29,13 +37,13 @@ type blurHashUpdater struct { func newBlurHashUpdater(a *artwork) *blurHashUpdater { return &blurHashUpdater{ a: a, - buffer: make(map[model.ArtworkID]bool), + buffer: make(map[model.ArtworkID]enqueueRequest), noResult: make(map[model.ArtworkID]time.Time), wake: make(chan struct{}, 1), } } -func (u *blurHashUpdater) Enqueue(artID model.ArtworkID, force bool) { +func (u *blurHashUpdater) Enqueue(artID model.ArtworkID, imageUpdatedAt time.Time, force bool) { switch artID.Kind { case model.KindAlbumArtwork, model.KindArtistArtwork, model.KindPlaylistArtwork: default: @@ -47,7 +55,12 @@ func (u *blurHashUpdater) Enqueue(artID model.ArtworkID, force bool) { go u.run(request.WithUser(context.Background(), model.User{IsAdmin: true})) }) u.mutex.Lock() - u.buffer[artID] = u.buffer[artID] || force + req := u.buffer[artID] + req.force = req.force || force + if imageUpdatedAt.After(req.imageUpdatedAt) { + req.imageUpdatedAt = imageUpdatedAt + } + u.buffer[artID] = req u.mutex.Unlock() select { case u.wake <- struct{}{}: @@ -58,30 +71,30 @@ func (u *blurHashUpdater) Enqueue(artID model.ArtworkID, force bool) { func (u *blurHashUpdater) run(ctx context.Context) { for range u.wake { for { - artID, force, ok := u.next() + artID, req, ok := u.next() if !ok { break } - u.process(ctx, artID, force) + u.process(ctx, artID, req) } } } -func (u *blurHashUpdater) next() (model.ArtworkID, bool, bool) { +func (u *blurHashUpdater) next() (model.ArtworkID, enqueueRequest, bool) { u.mutex.Lock() defer u.mutex.Unlock() - for artID, force := range u.buffer { + for artID, req := range u.buffer { delete(u.buffer, artID) - return artID, force, true + return artID, req, true } - return model.ArtworkID{}, false, false + return model.ArtworkID{}, enqueueRequest{}, false } // processTimeout bounds one computation: readers can call external agents, and a hung call must // not stall the worker forever. const processTimeout = 30 * time.Second -func (u *blurHashUpdater) process(ctx context.Context, artID model.ArtworkID, force bool) { +func (u *blurHashUpdater) process(ctx context.Context, artID model.ArtworkID, req enqueueRequest) { // Artwork readers can touch storage, agents and plugins; a panic here must not kill the server. defer func() { if r := recover(); r != nil { @@ -95,21 +108,31 @@ func (u *blurHashUpdater) process(ctx context.Context, artID model.ArtworkID, fo log.Trace(ctx, "BlurHash: could not load entity", "artID", artID, err) return } - if !force { - if stored != "" && storedAt != nil && storedAt.Equal(version) { + // sig is the newest staleness signal: the entity's artwork version or the served image's own + // timestamp, whichever is later (file swaps move the latter without touching any row). + sig := version + if req.imageUpdatedAt.After(sig) { + sig = req.imageUpdatedAt + } + if !req.force { + if stored != "" && storedAt != nil && storedAt.Equal(version) && !sig.After(*storedAt) { return } - if last, ok := u.lastNoResult(artID); ok && last.Equal(version) { + if last, ok := u.lastNoResult(artID); ok && !sig.After(last) { return } } hash, err := u.computeFromArtwork(ctx, artID) + if err != nil && !errors.Is(err, ErrUnavailable) { + // Transient failure (timeout, storage/agent hiccup) — leave un-memoized so a later serve retries. + log.Trace(ctx, "BlurHash: compute failed", "artID", artID, err) + return + } if err != nil || hash == "" { + // Definitively no artwork (or a placeholder) for this state — remember it, so browsing + // artwork-less entities doesn't re-resolve them on every serve. log.Trace(ctx, "BlurHash: nothing to persist", "artID", artID, err) - // A timed-out/cancelled attempt is transient — don't suppress future retries for it. - if ctx.Err() == nil { - u.setNoResult(artID, version) - } + u.setNoResult(artID, sig) return } if err := u.persist(ctx, artID, hash, version); err != nil { diff --git a/core/artwork/blurhash_updater_internal_test.go b/core/artwork/blurhash_updater_internal_test.go index 70022bd51..ec099eb91 100644 --- a/core/artwork/blurhash_updater_internal_test.go +++ b/core/artwork/blurhash_updater_internal_test.go @@ -18,25 +18,28 @@ var _ = Describe("blurHashUpdater", func() { // No run() goroutine: tests drive next()/process() directly. u = &blurHashUpdater{ a: &artwork{ds: ds}, - buffer: make(map[model.ArtworkID]bool), + buffer: make(map[model.ArtworkID]enqueueRequest), noResult: make(map[model.ArtworkID]time.Time), wake: make(chan struct{}, 1), } }) Describe("Enqueue", func() { - It("accepts album, artist and playlist artwork and dedups, keeping the force flag sticky", func() { + It("accepts album, artist and playlist artwork and dedups, merging force and newest image time", func() { id := model.Album{ID: "al-1"}.CoverArtID() - u.Enqueue(id, true) - u.Enqueue(id, false) - u.Enqueue(model.Artist{ID: "ar-1"}.CoverArtID(), false) + t1 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + t2 := t1.Add(time.Hour) + u.Enqueue(id, t2, true) + u.Enqueue(id, t1, false) + u.Enqueue(model.Artist{ID: "ar-1"}.CoverArtID(), t1, false) Expect(u.buffer).To(HaveLen(2)) - Expect(u.buffer[id]).To(BeTrue()) + Expect(u.buffer[id].force).To(BeTrue()) + Expect(u.buffer[id].imageUpdatedAt).To(Equal(t2)) }) It("ignores other artwork kinds", func() { - u.Enqueue(model.ArtworkID{Kind: model.KindMediaFileArtwork, ID: "mf-1"}, false) - u.Enqueue(model.ArtworkID{Kind: model.KindRadioArtwork, ID: "ra-1"}, true) + u.Enqueue(model.ArtworkID{Kind: model.KindMediaFileArtwork, ID: "mf-1"}, time.Time{}, false) + u.Enqueue(model.ArtworkID{Kind: model.KindRadioArtwork, ID: "ra-1"}, time.Time{}, true) Expect(u.buffer).To(BeEmpty()) }) }) @@ -54,27 +57,45 @@ var _ = Describe("blurHashUpdater", func() { repo.SetData(model.Albums{al}) ds.MockedAlbum = repo - u.process(GinkgoT().Context(), al.CoverArtID(), false) + u.process(GinkgoT().Context(), al.CoverArtID(), enqueueRequest{imageUpdatedAt: version}) stored, err := ds.Album(GinkgoT().Context()).Get("al-1") Expect(err).ToNot(HaveOccurred()) Expect(stored.BlurHash).To(Equal("LEHV6nWB2yk8")) }) - It("skips entities that previously yielded no result for the same artwork version", func() { + It("skips entities that previously yielded no result for the same signals", func() { al := model.Album{ID: "al-1", UpdatedAt: version} repo := tests.CreateMockAlbumRepo() repo.SetData(model.Albums{al}) ds.MockedAlbum = repo - u.setNoResult(al.CoverArtID(), al.ArtworkUpdatedAt()) + u.setNoResult(al.CoverArtID(), version) - u.process(GinkgoT().Context(), al.CoverArtID(), false) + u.process(GinkgoT().Context(), al.CoverArtID(), enqueueRequest{imageUpdatedAt: version}) stored, _ := ds.Album(GinkgoT().Context()).Get("al-1") Expect(stored.BlurHash).To(BeEmpty()) }) + It("does not refresh the no-result entry when a retry fails transiently", func() { + al := model.Album{ID: "al-1", UpdatedAt: version} + repo := tests.CreateMockAlbumRepo() + repo.SetData(model.Albums{al}) + ds.MockedAlbum = repo + u.setNoResult(al.CoverArtID(), version) + + // A newer image mtime must bypass the no-result skip and attempt a compute; the compute + // fails transiently here (no readers wired), so the no-result entry must NOT be refreshed. + newer := version.Add(time.Hour) + u.process(GinkgoT().Context(), al.CoverArtID(), enqueueRequest{imageUpdatedAt: newer}) + last, ok := u.lastNoResult(al.CoverArtID()) + Expect(ok).To(BeTrue()) + Expect(last).To(Equal(version)) + }) + It("does nothing when the entity is gone", func() { ds.MockedAlbum = tests.CreateMockAlbumRepo() - Expect(func() { u.process(GinkgoT().Context(), model.Album{ID: "missing"}.CoverArtID(), false) }).ToNot(Panic()) + Expect(func() { + u.process(GinkgoT().Context(), model.Album{ID: "missing"}.CoverArtID(), enqueueRequest{}) + }).ToNot(Panic()) }) }) }) From 5bdeaad95e1b5b12b5dd371278e2eed09c57866e Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 17 Jul 2026 11:33:16 -0400 Subject: [PATCH 11/46] perf(artwork): eliminate per-pixel allocations in blurhash encoding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Normalize the downscaled image to *image.RGBA once and read Pix directly (the image.At interface boxed a color per pixel — ~16k allocs/encode), and replace per-pixel math.Pow with a 256-entry sRGB-to-linear table. ~72% faster (3.0ms -> 0.86ms for covers >=300px), 19 allocs/op. Benchmark included. --- core/artwork/blurhash/blurhash.go | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/core/artwork/blurhash/blurhash.go b/core/artwork/blurhash/blurhash.go index f29a295bb..ba466f467 100644 --- a/core/artwork/blurhash/blurhash.go +++ b/core/artwork/blurhash/blurhash.go @@ -8,6 +8,7 @@ import ( "image/draw" "math" "strings" + "sync" xdraw "golang.org/x/image/draw" ) @@ -32,8 +33,8 @@ func Encode(img image.Image, xComp, yComp int) (string, error) { if xComp < 1 || xComp > 9 || yComp < 1 || yComp > 9 { return "", errors.New("blurhash: components must be between 1 and 9") } - img = downscale(img) - bounds := img.Bounds() + rgba := toRGBA(downscale(img)) + bounds := rgba.Bounds() w, h := bounds.Dx(), bounds.Dy() if w == 0 || h == 0 { return "", errors.New("blurhash: empty image") @@ -54,11 +55,13 @@ func Encode(img image.Image, xComp, yComp int) (string, error) { } } + lin := srgbToLinearTable() factors := make([][3]float64, xComp*yComp) for y := 0; y < h; y++ { + row := rgba.Pix[y*rgba.Stride:] for x := 0; x < w; x++ { - r, g, b, _ := img.At(bounds.Min.X+x, bounds.Min.Y+y).RGBA() - lr, lg, lb := srgbToLinear(int(r>>8)), srgbToLinear(int(g>>8)), srgbToLinear(int(b>>8)) + p := x * 4 + lr, lg, lb := lin[row[p]], lin[row[p+1]], lin[row[p+2]] for j := 0; j < yComp; j++ { for i := 0; i < xComp; i++ { basis := cosX[i][x] * cosY[j][y] @@ -106,6 +109,26 @@ func Encode(img image.Image, xComp, yComp int) (string, error) { return sb.String(), nil } +// toRGBA gives the pixel loop direct Pix access, avoiding a per-pixel allocation through the +// image.At interface (~16k allocs per encode). +func toRGBA(img image.Image) *image.RGBA { + if rgba, ok := img.(*image.RGBA); ok { + return rgba + } + b := img.Bounds() + dst := image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy())) + draw.Draw(dst, dst.Bounds(), img, b.Min, draw.Src) + return dst +} + +var srgbToLinearTable = sync.OnceValue(func() *[256]float64 { + var t [256]float64 + for i := range t { + t[i] = srgbToLinear(i) + } + return &t +}) + func downscale(img image.Image) image.Image { b := img.Bounds() w, h := b.Dx(), b.Dy() From b040345fb0ff94b1be6d3a8f9e649cd3bcbab36b Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 17 Jul 2026 11:51:24 -0400 Subject: [PATCH 12/46] fix(artwork): stop the blurhash worker on Close to fix test data races CI's race detector caught the process-lifetime worker goroutine outliving Ginkgo specs and touching mocks/fake filesystems being torn down. The worker now has a stop() that waits for in-flight work, exposed as Close() on the artwork service; unit suites close it up front (they don't exercise blurhash), the artwork e2e suite closes it on cleanup. --- core/artwork/artwork.go | 9 ++++ core/artwork/artwork_internal_test.go | 2 + core/artwork/artwork_test.go | 2 + core/artwork/benchmark_e2e_test.go | 1 + core/artwork/blurhash_updater.go | 47 ++++++++++++++++--- .../artwork/blurhash_updater_internal_test.go | 3 +- core/artwork/e2e/suite_test.go | 3 ++ 7 files changed, 59 insertions(+), 8 deletions(-) diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go index 3b403ae6c..a021e7e46 100644 --- a/core/artwork/artwork.go +++ b/core/artwork/artwork.go @@ -30,6 +30,15 @@ func NewArtwork(ds model.DataStore, cache cache.FileCache, ffmpeg ffmpeg.FFmpeg, return a } +// Close stops the background blurhash worker. The server never calls it; tests must, so a leaked +// worker can't touch mocks/filesystems being torn down by the next spec. +func (a *artwork) Close() error { + if a.blurHashes != nil { + a.blurHashes.stop() + } + return nil +} + type artwork struct { ds model.DataStore cache cache.FileCache diff --git a/core/artwork/artwork_internal_test.go b/core/artwork/artwork_internal_test.go index c95371959..cdb3d3204 100644 --- a/core/artwork/artwork_internal_test.go +++ b/core/artwork/artwork_internal_test.go @@ -67,6 +67,8 @@ var _ = Describe("Artwork", func() { cache := GetImageCache() ffmpeg = tests.NewMockFFmpeg("content from ffmpeg") aw = NewArtwork(ds, cache, ffmpeg, nil).(*artwork) + // Stop the blurhash worker up front: it would mutate the non-thread-safe mocks mid-spec. + Expect(aw.Close()).To(Succeed()) }) Describe("albumArtworkReader", func() { diff --git a/core/artwork/artwork_test.go b/core/artwork/artwork_test.go index adddd0dc3..b3e7b9421 100644 --- a/core/artwork/artwork_test.go +++ b/core/artwork/artwork_test.go @@ -26,6 +26,8 @@ var _ = Describe("Artwork", func() { cache := artwork.GetImageCache() ffmpeg = tests.NewMockFFmpeg("content from ffmpeg") aw = artwork.NewArtwork(ds, cache, ffmpeg, nil) + // Stop the blurhash worker up front: it would mutate the non-thread-safe mocks mid-spec. + Expect(aw.(io.Closer).Close()).To(Succeed()) }) Context("GetOrPlaceholder", func() { diff --git a/core/artwork/benchmark_e2e_test.go b/core/artwork/benchmark_e2e_test.go index bf3d435a8..69a04a7c3 100644 --- a/core/artwork/benchmark_e2e_test.go +++ b/core/artwork/benchmark_e2e_test.go @@ -95,6 +95,7 @@ func setupE2EBenchmark(b *testing.B, cacheSize string) (Artwork, model.ArtworkID aw := NewArtwork(ds, imgCache, ffmpeg, nil) cleanupAll := func() { + _ = aw.(*artwork).Close() os.RemoveAll(tmpDir) } return aw, artID, cleanupAll diff --git a/core/artwork/blurhash_updater.go b/core/artwork/blurhash_updater.go index 735256baa..4457ff725 100644 --- a/core/artwork/blurhash_updater.go +++ b/core/artwork/blurhash_updater.go @@ -31,7 +31,10 @@ type blurHashUpdater struct { buffer map[model.ArtworkID]enqueueRequest noResult map[model.ArtworkID]time.Time wake chan struct{} - start sync.Once + done chan struct{} + runDone chan struct{} + started bool + stopped bool } func newBlurHashUpdater(a *artwork) *blurHashUpdater { @@ -40,6 +43,8 @@ func newBlurHashUpdater(a *artwork) *blurHashUpdater { buffer: make(map[model.ArtworkID]enqueueRequest), noResult: make(map[model.ArtworkID]time.Time), wake: make(chan struct{}, 1), + done: make(chan struct{}), + runDone: make(chan struct{}), } } @@ -49,12 +54,17 @@ func (u *blurHashUpdater) Enqueue(artID model.ArtworkID, imageUpdatedAt time.Tim default: return } - u.start.Do(func() { - // Playlist artwork readers require a user in the context. Like the cacheWarmer, the worker - // lives for the rest of the process; lazy-starting keeps idle Artwork instances goroutine-free. - go u.run(request.WithUser(context.Background(), model.User{IsAdmin: true})) - }) u.mutex.Lock() + if u.stopped { + u.mutex.Unlock() + return + } + if !u.started { + u.started = true + // Playlist artwork readers require a user in the context. Lazy-starting keeps idle Artwork + // instances goroutine-free; stop() ends the worker (tests must call it, the server never does). + go u.run(request.WithUser(context.Background(), model.User{IsAdmin: true})) + } req := u.buffer[artID] req.force = req.force || force if imageUpdatedAt.After(req.imageUpdatedAt) { @@ -68,8 +78,31 @@ func (u *blurHashUpdater) Enqueue(artID model.ArtworkID, imageUpdatedAt time.Tim } } +// stop ends the worker and waits for any in-flight computation, so callers can safely tear down +// the resources (DataStore, filesystems) the worker touches. +func (u *blurHashUpdater) stop() { + u.mutex.Lock() + if u.stopped { + u.mutex.Unlock() + return + } + u.stopped = true + started := u.started + u.mutex.Unlock() + close(u.done) + if started { + <-u.runDone + } +} + func (u *blurHashUpdater) run(ctx context.Context) { - for range u.wake { + defer close(u.runDone) + for { + select { + case <-u.done: + return + case <-u.wake: + } for { artID, req, ok := u.next() if !ok { diff --git a/core/artwork/blurhash_updater_internal_test.go b/core/artwork/blurhash_updater_internal_test.go index ec099eb91..e996e2abe 100644 --- a/core/artwork/blurhash_updater_internal_test.go +++ b/core/artwork/blurhash_updater_internal_test.go @@ -15,12 +15,13 @@ var _ = Describe("blurHashUpdater", func() { BeforeEach(func() { ds = &tests.MockDataStore{} - // No run() goroutine: tests drive next()/process() directly. + // started is pre-set so Enqueue never spawns run(): tests drive next()/process() directly. u = &blurHashUpdater{ a: &artwork{ds: ds}, buffer: make(map[model.ArtworkID]enqueueRequest), noResult: make(map[model.ArtworkID]time.Time), wake: make(chan struct{}, 1), + started: true, } }) diff --git a/core/artwork/e2e/suite_test.go b/core/artwork/e2e/suite_test.go index 06cc05b6f..1ca4f2e2a 100644 --- a/core/artwork/e2e/suite_test.go +++ b/core/artwork/e2e/suite_test.go @@ -3,6 +3,7 @@ package artworke2e_test import ( "context" "fmt" + "io" "path/filepath" "testing" @@ -88,6 +89,8 @@ func setupHarness() { storagetest.Register(fakeLibScheme, fakeFS) aw = artwork.NewArtwork(ds, artwork.GetImageCache(), newNoopFFmpeg(), &noopProvider{}) + // The worker must not outlive the spec: it would race the next spec's fakeFS/DB swaps. + DeferCleanup(aw.(io.Closer).Close) } func scan() { From 43500c0ffe88cb7c19977d471ffa8e93aa7c891e Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 17 Jul 2026 11:59:24 -0400 Subject: [PATCH 13/46] fix(artwork): persist the image freshness signal to stop per-serve recomputes When a cover file's mtime is newer than the entity row (common), persisting the row version made the freshness check fail on every serve, re-encoding and re-writing the same hash each time. The snapshot now stores the newest signal (row version or image mtime), and both freshness checks accept a snapshot at-or-after the row version. --- core/artwork/blurhash_updater.go | 6 ++++-- core/artwork/e2e/blurhash_test.go | 5 +++-- server/jellyfin/dto/blurhash.go | 7 ++++--- server/jellyfin/dto/blurhash_test.go | 5 +++++ 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/core/artwork/blurhash_updater.go b/core/artwork/blurhash_updater.go index 4457ff725..76077e0d1 100644 --- a/core/artwork/blurhash_updater.go +++ b/core/artwork/blurhash_updater.go @@ -148,7 +148,9 @@ func (u *blurHashUpdater) process(ctx context.Context, artID model.ArtworkID, re sig = req.imageUpdatedAt } if !req.force { - if stored != "" && storedAt != nil && storedAt.Equal(version) && !sig.After(*storedAt) { + // Current when computed from this row version or later; the snapshot may exceed the row + // version because file mtimes (which don't move rows) are folded into it on persist. + if stored != "" && storedAt != nil && !storedAt.Before(version) && !sig.After(*storedAt) { return } if last, ok := u.lastNoResult(artID); ok && !sig.After(last) { @@ -168,7 +170,7 @@ func (u *blurHashUpdater) process(ctx context.Context, artID model.ArtworkID, re u.setNoResult(artID, sig) return } - if err := u.persist(ctx, artID, hash, version); err != nil { + if err := u.persist(ctx, artID, hash, sig); err != nil { log.Warn(ctx, "BlurHash: error persisting", "artID", artID, err) return } diff --git a/core/artwork/e2e/blurhash_test.go b/core/artwork/e2e/blurhash_test.go index 9d69ed27c..0ea75d509 100644 --- a/core/artwork/e2e/blurhash_test.go +++ b/core/artwork/e2e/blurhash_test.go @@ -29,8 +29,9 @@ var _ = Describe("BlurHash", func() { g.Expect(err).ToNot(HaveOccurred()) g.Expect(len(updated.BlurHash)).To(BeNumerically(">", 6)) g.Expect(updated.BlurHashUpdatedAt).ToNot(BeNil()) - // The snapshot must match the artwork version, or the DTO layer would treat it as stale. - g.Expect(updated.BlurHashUpdatedAt.Equal(updated.ArtworkUpdatedAt())).To(BeTrue()) + // The snapshot must not be before the artwork version, or the DTO would treat it as + // stale (it may exceed it: image file mtimes are folded in). + g.Expect(updated.BlurHashUpdatedAt.Before(updated.ArtworkUpdatedAt())).To(BeFalse()) }, "10s", "100ms").Should(Succeed()) }) diff --git a/server/jellyfin/dto/blurhash.go b/server/jellyfin/dto/blurhash.go index cc846bb81..d0404d6c3 100644 --- a/server/jellyfin/dto/blurhash.go +++ b/server/jellyfin/dto/blurhash.go @@ -40,10 +40,11 @@ func blurHash(seed string) string { } // primaryBlurHash returns the stored blurhash when it was computed from the entity's current -// artwork version; otherwise a fake seeded by id+version, so the value still rotates on any -// artwork change (Finamp keys its cover caches by this value; tags never reach its image URLs). +// artwork version or later (the snapshot folds in image file mtimes, which can exceed row +// timestamps); otherwise a fake seeded by id+version, so the value still rotates on any artwork +// change (Finamp keys its cover caches by this value; tags never reach its image URLs). func primaryBlurHash(stored string, storedAt *time.Time, id string, version time.Time) string { - if stored != "" && storedAt != nil && storedAt.Equal(version) { + if stored != "" && storedAt != nil && !storedAt.Before(version) { return stored } return blurHash(fmt.Sprintf("%s-%x", id, version.UnixMilli())) diff --git a/server/jellyfin/dto/blurhash_test.go b/server/jellyfin/dto/blurhash_test.go index 78cd242f8..5fc74f293 100644 --- a/server/jellyfin/dto/blurhash_test.go +++ b/server/jellyfin/dto/blurhash_test.go @@ -34,6 +34,11 @@ var _ = Describe("primaryBlurHash", func() { Expect(primaryBlurHash("LEHV6nWB2yk8", &version, "id-1", version)).To(Equal("LEHV6nWB2yk8")) }) + It("returns the stored hash when the snapshot is newer than the version (image mtime)", func() { + newer := version.Add(time.Hour) + Expect(primaryBlurHash("LEHV6nWB2yk8", &newer, "id-1", version)).To(Equal("LEHV6nWB2yk8")) + }) + It("falls back to a fake when there is no stored hash", func() { h := primaryBlurHash("", nil, "id-1", version) Expect(h).To(HaveLen(6)) From f9f6d36ebbeb387cc76a4d7808054616dc5eee0d Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 17 Jul 2026 12:11:30 -0400 Subject: [PATCH 14/46] fix(artwork): TTL for no-result memoization, prompt Close, no warmup force spike - memoize every no-result outcome (ErrUnavailable can wrap transient agent and storage failures, so it is not a reliable definitive/transient discriminator) but bound it with a 1h TTL, which also un-poisons entries recorded under future file mtimes - cancel the worker context and check done in the drain loop, so Close returns promptly instead of draining the backlog at up to 30s per item - only force recompute when the image cache is operational: during warmup every serve is a miss, which caused a recompute spike at startup --- core/artwork/artwork.go | 7 +- core/artwork/blurhash_updater.go | 70 +++++++++++-------- .../artwork/blurhash_updater_internal_test.go | 19 +++-- 3 files changed, 58 insertions(+), 38 deletions(-) diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go index a021e7e46..9e909068f 100644 --- a/core/artwork/artwork.go +++ b/core/artwork/artwork.go @@ -83,10 +83,9 @@ func (a *artwork) Get(ctx context.Context, artID model.ArtworkID, size int, squa return nil, time.Time{}, err } if a.blurHashes != nil { - // A cache miss means the image is new or changed, even when no entity row moved (e.g. an - // in-place cover.jpg swap) — force a recompute so the stored blurhash follows the image. - // The reader's LastUpdated covers the same case when the image cache is disabled. - force := !r.Cached && !a.cache.Disabled(ctx) + // A miss on an operational cache means a new/changed image even when no entity row moved; + // while warming up or disabled every serve misses, so only the LastUpdated signal applies. + force := !r.Cached && a.cache.Available(ctx) a.blurHashes.Enqueue(artID, artReader.LastUpdated(), force) } return r, artReader.LastUpdated(), nil diff --git a/core/artwork/blurhash_updater.go b/core/artwork/blurhash_updater.go index 76077e0d1..e2d7c2f4b 100644 --- a/core/artwork/blurhash_updater.go +++ b/core/artwork/blurhash_updater.go @@ -2,7 +2,6 @@ package artwork import ( "context" - "errors" "fmt" "image" "sync" @@ -15,9 +14,6 @@ import ( "github.com/navidrome/navidrome/model/request" ) -// 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. // enqueueRequest carries the staleness signals seen at serve time: force (image-cache miss) and // the reader's LastUpdated, which tracks file mtimes that no entity row timestamp reflects. type enqueueRequest struct { @@ -25,23 +21,35 @@ type enqueueRequest struct { imageUpdatedAt time.Time } +// blurHashUpdater keeps stored blurhashes in sync with the artwork actually served: Enqueue is a +// cheap dedup insert, and a single worker re-checks freshness before decoding. type blurHashUpdater struct { - a *artwork - mutex sync.Mutex - buffer map[model.ArtworkID]enqueueRequest - noResult map[model.ArtworkID]time.Time - wake chan struct{} - done chan struct{} - runDone chan struct{} - started bool - stopped bool + a *artwork + mutex sync.Mutex + buffer map[model.ArtworkID]enqueueRequest + noResult map[model.ArtworkID]noResultEntry + wake chan struct{} + done chan struct{} + runDone chan struct{} + runCancel context.CancelFunc + started bool + stopped bool +} + +// noResultTTL bounds how long a failed or empty computation suppresses retries, so transient +// outages (agents, storage) self-heal despite being indistinguishable from "no artwork". +const noResultTTL = time.Hour + +type noResultEntry struct { + sig time.Time + at time.Time } func newBlurHashUpdater(a *artwork) *blurHashUpdater { return &blurHashUpdater{ a: a, buffer: make(map[model.ArtworkID]enqueueRequest), - noResult: make(map[model.ArtworkID]time.Time), + noResult: make(map[model.ArtworkID]noResultEntry), wake: make(chan struct{}, 1), done: make(chan struct{}), runDone: make(chan struct{}), @@ -61,9 +69,11 @@ func (u *blurHashUpdater) Enqueue(artID model.ArtworkID, imageUpdatedAt time.Tim } if !u.started { u.started = true - // Playlist artwork readers require a user in the context. Lazy-starting keeps idle Artwork + // Admin context: playlist artwork readers require a user. Lazy-starting keeps idle Artwork // instances goroutine-free; stop() ends the worker (tests must call it, the server never does). - go u.run(request.WithUser(context.Background(), model.User{IsAdmin: true})) + ctx, cancel := context.WithCancel(request.WithUser(context.Background(), model.User{IsAdmin: true})) + u.runCancel = cancel + go u.run(ctx) } req := u.buffer[artID] req.force = req.force || force @@ -88,9 +98,11 @@ func (u *blurHashUpdater) stop() { } u.stopped = true started := u.started + cancel := u.runCancel u.mutex.Unlock() close(u.done) if started { + cancel() <-u.runDone } } @@ -104,6 +116,11 @@ func (u *blurHashUpdater) run(ctx context.Context) { case <-u.wake: } for { + select { + case <-u.done: + return + default: + } artID, req, ok := u.next() if !ok { break @@ -153,19 +170,14 @@ func (u *blurHashUpdater) process(ctx context.Context, artID model.ArtworkID, re if stored != "" && storedAt != nil && !storedAt.Before(version) && !sig.After(*storedAt) { return } - if last, ok := u.lastNoResult(artID); ok && !sig.After(last) { + if last, ok := u.lastNoResult(artID); ok && !sig.After(last.sig) && time.Since(last.at) < noResultTTL { return } } hash, err := u.computeFromArtwork(ctx, artID) - if err != nil && !errors.Is(err, ErrUnavailable) { - // Transient failure (timeout, storage/agent hiccup) — leave un-memoized so a later serve retries. - log.Trace(ctx, "BlurHash: compute failed", "artID", artID, err) - return - } if err != nil || hash == "" { - // Definitively no artwork (or a placeholder) for this state — remember it, so browsing - // artwork-less entities doesn't re-resolve them on every serve. + // Any no-result (no artwork, placeholder, decode failure, transient outage) is memoized + // with a TTL: browsing stays cheap, and failures still retry once it expires. log.Trace(ctx, "BlurHash: nothing to persist", "artID", artID, err) u.setNoResult(artID, sig) return @@ -177,24 +189,24 @@ func (u *blurHashUpdater) process(ctx context.Context, artID model.ArtworkID, re u.clearNoResult(artID) } -func (u *blurHashUpdater) lastNoResult(artID model.ArtworkID) (time.Time, bool) { +func (u *blurHashUpdater) lastNoResult(artID model.ArtworkID) (noResultEntry, bool) { u.mutex.Lock() defer u.mutex.Unlock() - t, ok := u.noResult[artID] - return t, ok + e, ok := u.noResult[artID] + return e, ok } // maxNoResultEntries bounds the negative cache; entries only accumulate for artwork-less entities, // so a wholesale reset just costs those entities one extra verification pass each. const maxNoResultEntries = 25_000 -func (u *blurHashUpdater) setNoResult(artID model.ArtworkID, version time.Time) { +func (u *blurHashUpdater) setNoResult(artID model.ArtworkID, sig time.Time) { u.mutex.Lock() defer u.mutex.Unlock() if len(u.noResult) >= maxNoResultEntries { clear(u.noResult) } - u.noResult[artID] = version + u.noResult[artID] = noResultEntry{sig: sig, at: time.Now()} } func (u *blurHashUpdater) clearNoResult(artID model.ArtworkID) { diff --git a/core/artwork/blurhash_updater_internal_test.go b/core/artwork/blurhash_updater_internal_test.go index e996e2abe..8120649d5 100644 --- a/core/artwork/blurhash_updater_internal_test.go +++ b/core/artwork/blurhash_updater_internal_test.go @@ -1,6 +1,7 @@ package artwork import ( + "errors" "time" "github.com/navidrome/navidrome/model" @@ -9,6 +10,13 @@ import ( . "github.com/onsi/gomega" ) +// failingFolderRepo makes the artwork reader chain fail with a clean (transient-style) error. +type failingFolderRepo struct{ model.FolderRepository } + +func (failingFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) { + return nil, errors.New("boom") +} + var _ = Describe("blurHashUpdater", func() { var u *blurHashUpdater var ds *tests.MockDataStore @@ -19,7 +27,7 @@ var _ = Describe("blurHashUpdater", func() { u = &blurHashUpdater{ a: &artwork{ds: ds}, buffer: make(map[model.ArtworkID]enqueueRequest), - noResult: make(map[model.ArtworkID]time.Time), + noResult: make(map[model.ArtworkID]noResultEntry), wake: make(chan struct{}, 1), started: true, } @@ -76,20 +84,21 @@ var _ = Describe("blurHashUpdater", func() { Expect(stored.BlurHash).To(BeEmpty()) }) - It("does not refresh the no-result entry when a retry fails transiently", func() { + It("memoizes a failed retry under the newer signal", func() { al := model.Album{ID: "al-1", UpdatedAt: version} repo := tests.CreateMockAlbumRepo() repo.SetData(model.Albums{al}) ds.MockedAlbum = repo + ds.MockedFolder = failingFolderRepo{} u.setNoResult(al.CoverArtID(), version) - // A newer image mtime must bypass the no-result skip and attempt a compute; the compute - // fails transiently here (no readers wired), so the no-result entry must NOT be refreshed. + // A newer image mtime bypasses the no-result skip; the compute fails cleanly here, so + // the entry is refreshed under the newer signal, with the TTL as the retry bound. newer := version.Add(time.Hour) u.process(GinkgoT().Context(), al.CoverArtID(), enqueueRequest{imageUpdatedAt: newer}) last, ok := u.lastNoResult(al.CoverArtID()) Expect(ok).To(BeTrue()) - Expect(last).To(Equal(version)) + Expect(last.sig).To(Equal(newer)) }) It("does nothing when the entity is gone", func() { From 3a8505584b46fbb47850f930a9ef297ef98c6311 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 17 Jul 2026 12:27:13 -0400 Subject: [PATCH 15/46] 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: From c4ca3dca5e8c6c72120303ef7f16769177b91539 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 17 Jul 2026 12:48:36 -0400 Subject: [PATCH 16/46] fix(artwork): clear the stored hash when a recompute yields no result A cover deleted or corrupted in place (mtime-only signal, row unchanged) left the old blur_hash in the DB, and the DTO kept emitting a hash for artwork no longer served. Since the worker only reaches compute when there is change evidence, a no-result with a stored hash now clears it, making the DTO fall back to the rotating fake. --- core/artwork/blurhash_updater.go | 7 +++++++ core/artwork/blurhash_updater_internal_test.go | 15 +++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/core/artwork/blurhash_updater.go b/core/artwork/blurhash_updater.go index cbbbe869a..d44797642 100644 --- a/core/artwork/blurhash_updater.go +++ b/core/artwork/blurhash_updater.go @@ -183,6 +183,13 @@ func (u *blurHashUpdater) process(ctx context.Context, artID model.ArtworkID, re // with a TTL: browsing stays cheap, and failures still retry once it expires. log.Trace(ctx, "BlurHash: nothing to persist", "artID", artID, err) u.setNoResult(artID, sig) + // Reaching compute with a stored hash means there was change evidence — clear it, so the + // DTO falls back to the rotating fake instead of describing artwork no longer served. + if stored != "" { + if err := u.persist(ctx, artID, "", sig); err != nil { + log.Warn(ctx, "BlurHash: error clearing stale hash", "artID", artID, err) + } + } return } if err := u.persist(ctx, artID, hash, sig); err != nil { diff --git a/core/artwork/blurhash_updater_internal_test.go b/core/artwork/blurhash_updater_internal_test.go index 8120649d5..1807ca0d1 100644 --- a/core/artwork/blurhash_updater_internal_test.go +++ b/core/artwork/blurhash_updater_internal_test.go @@ -101,6 +101,21 @@ var _ = Describe("blurHashUpdater", func() { Expect(last.sig).To(Equal(newer)) }) + It("clears a stored hash when a recompute with change evidence yields no result", func() { + storedAt := version.Add(-time.Hour) + al := model.Album{ID: "al-1", UpdatedAt: version, BlurHash: "LEHV6nWB2yk8", BlurHashUpdatedAt: &storedAt} + repo := tests.CreateMockAlbumRepo() + repo.SetData(model.Albums{al}) + ds.MockedAlbum = repo + ds.MockedFolder = failingFolderRepo{} + + // storedAt < version = change evidence; the compute fails, so the stale hash must go. + u.process(GinkgoT().Context(), al.CoverArtID(), enqueueRequest{imageUpdatedAt: version}) + stored, err := ds.Album(GinkgoT().Context()).Get("al-1") + Expect(err).ToNot(HaveOccurred()) + Expect(stored.BlurHash).To(BeEmpty()) + }) + It("does nothing when the entity is gone", func() { ds.MockedAlbum = tests.CreateMockAlbumRepo() Expect(func() { From 9ac2c6a5e3bf56f98533f2aa0a99c8f1de1141e0 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 17 Jul 2026 12:57:33 -0400 Subject: [PATCH 17/46] fix(model): exclude blur hash fields from full-row writes Scanner and maintenance paths build fresh entities with empty blur hash fields, so every ordinary refresh erased the computed hash and caused a double Finamp cover refetch (fake, then real again). The fields are now read-only projections (structs:"-"): only UpdateBlurHash writes them. --- model/album.go | 7 ++++--- model/artist.go | 4 ++-- model/playlist.go | 4 ++-- persistence/album_repository_test.go | 8 ++++++++ 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/model/album.go b/model/album.go index 4e8dfae51..f4d2fdedb 100644 --- a/model/album.go +++ b/model/album.go @@ -68,9 +68,10 @@ type Album struct { CreatedAt time.Time `structs:"created_at" json:"createdAt"` // Oldest CreatedAt for all songs in this album UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` // Newest UpdatedAt for all songs in this album - // BlurHash of the album cover, computed asynchronously from the served artwork. - BlurHash string `structs:"blur_hash" json:"blurHash,omitempty" hash:"ignore"` - BlurHashUpdatedAt *time.Time `structs:"blur_hash_updated_at" json:"-" hash:"ignore"` + // BlurHash of the album cover, computed asynchronously from the served artwork. Excluded from + // full-row writes (structs:"-"): only UpdateBlurHash writes it, so scans can't erase it. + BlurHash string `structs:"-" json:"blurHash,omitempty" hash:"ignore"` + BlurHashUpdatedAt *time.Time `structs:"-" json:"-" hash:"ignore"` } func (a Album) CoverArtID() ArtworkID { diff --git a/model/artist.go b/model/artist.go index de7cd53be..12b1dc629 100644 --- a/model/artist.go +++ b/model/artist.go @@ -42,8 +42,8 @@ type Artist struct { CreatedAt *time.Time `structs:"created_at" json:"createdAt,omitempty"` UpdatedAt *time.Time `structs:"updated_at" json:"updatedAt,omitempty"` - BlurHash string `structs:"blur_hash" json:"blurHash,omitempty" hash:"ignore"` - BlurHashUpdatedAt *time.Time `structs:"blur_hash_updated_at" json:"-" hash:"ignore"` + BlurHash string `structs:"-" json:"blurHash,omitempty" hash:"ignore"` + BlurHashUpdatedAt *time.Time `structs:"-" json:"-" hash:"ignore"` } type ArtistStats struct { diff --git a/model/playlist.go b/model/playlist.go index 8970e27e2..bb12d7fe4 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -31,8 +31,8 @@ type Playlist struct { CreatedAt time.Time `structs:"created_at" json:"createdAt"` UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` - BlurHash string `structs:"blur_hash" json:"blurHash,omitempty" hash:"ignore"` - BlurHashUpdatedAt *time.Time `structs:"blur_hash_updated_at" json:"-" hash:"ignore"` + BlurHash string `structs:"-" json:"blurHash,omitempty" hash:"ignore"` + BlurHashUpdatedAt *time.Time `structs:"-" json:"-" hash:"ignore"` // SmartPlaylist attributes Rules *criteria.Criteria `structs:"rules" json:"rules"` diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index bbb821ab8..c8a66f8b0 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -928,5 +928,13 @@ var _ = Describe("AlbumRepository.UpdateBlurHash", func() { Expect(updated.BlurHashUpdatedAt.Equal(version)).To(BeTrue()) // The targeted update must not touch the row's own timestamps. Expect(updated.UpdatedAt).To(Equal(al.UpdatedAt)) + + // A full-row Put (e.g. a scanner refresh with empty BlurHash fields) must preserve the hash. + updated.BlurHash = "" + updated.BlurHashUpdatedAt = nil + Expect(repo.Put(updated)).To(Succeed()) + after, err := repo.Get(al.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(after.BlurHash).To(Equal("LKO2?U%2Tw=w]~RBVZRi};RPxuwH")) }) }) From 0e74cf0ab1a31c79e5950d6486d8dd50a21a18de Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 17 Jul 2026 13:16:07 -0400 Subject: [PATCH 18/46] fix(artwork): only force blurhash recompute on original-size cache misses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resized cache keys vary per requested size, so a first request for a new thumbnail size (or an evicted resized entry) forced a recompute with an unchanged source — wasted work, and a transient failure during it could clear a valid stored hash. Resized readers re-fetch the original through Get, so the original-size call still carries the real change signal. --- core/artwork/artwork.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go index 9e909068f..cd0d958e4 100644 --- a/core/artwork/artwork.go +++ b/core/artwork/artwork.go @@ -83,9 +83,11 @@ func (a *artwork) Get(ctx context.Context, artID model.ArtworkID, size int, squa return nil, time.Time{}, err } if a.blurHashes != nil { - // A miss on an operational cache means a new/changed image even when no entity row moved; - // while warming up or disabled every serve misses, so only the LastUpdated signal applies. - force := !r.Cached && a.cache.Available(ctx) + // An original-size miss on an operational cache means a new/changed image even when no + // entity row moved. Resized misses don't qualify (their keys vary per size, and their + // readers re-fetch the original through Get, carrying the real signal); nor does cache + // warmup/disabled, where every serve misses — there the LastUpdated signal applies. + force := size == 0 && !square && !r.Cached && a.cache.Available(ctx) a.blurHashes.Enqueue(artID, artReader.LastUpdated(), force) } return r, artReader.LastUpdated(), nil From a7eec3afc302d84ada6d6fbef962dfced9ac0a3b Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 17 Jul 2026 13:33:45 -0400 Subject: [PATCH 19/46] fix(artwork): clear the stored hash when the artwork source disappears A removed cover made Get error with ErrUnavailable before the enqueue, so the worker never saw the entity and the stale hash kept being emitted. The unavailable path now enqueues too; the worker's reader signal carries the folder change and the existing no-result clearing applies. Covered by an e2e spec exercising the full disappear-and-clear flow. --- core/artwork/artwork.go | 5 +++++ core/artwork/e2e/blurhash_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go index cd0d958e4..826e3ab19 100644 --- a/core/artwork/artwork.go +++ b/core/artwork/artwork.go @@ -80,6 +80,11 @@ func (a *artwork) Get(ctx context.Context, artID model.ArtworkID, size int, squa if !errors.Is(err, context.Canceled) && !errors.Is(err, ErrUnavailable) { log.Error(ctx, "Error accessing image cache", "id", artID, "size", size, err) } + // A vanished source must still reach the worker, or a stored hash would keep describing + // artwork that no longer exists. + if a.blurHashes != nil && errors.Is(err, ErrUnavailable) { + a.blurHashes.Enqueue(artID, artReader.LastUpdated(), false) + } return nil, time.Time{}, err } if a.blurHashes != nil { diff --git a/core/artwork/e2e/blurhash_test.go b/core/artwork/e2e/blurhash_test.go index 0ea75d509..90b759e6b 100644 --- a/core/artwork/e2e/blurhash_test.go +++ b/core/artwork/e2e/blurhash_test.go @@ -35,6 +35,34 @@ var _ = Describe("BlurHash", func() { }, "10s", "100ms").Should(Succeed()) }) + It("clears the stored blurhash when the cover disappears", func() { + setLayout(fstest.MapFS{ + "Artist/Album/01 - Song.mp3": trackFile(1, "Song"), + "Artist/Album/cover.png": realPNG("vanishing-cover"), + }) + scan() + al := firstAlbum() + readArtwork(al.CoverArtID()) + Eventually(func(g Gomega) { + updated, err := ds.Album(ctx).Get(al.ID) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(updated.BlurHash).ToNot(BeEmpty()) + }, "10s", "100ms").Should(Succeed()) + + setLayout(fstest.MapFS{ + "Artist/Album/01 - Song.mp3": trackFile(1, "Song"), + }) + scan() + _, err := readArtworkOrErr(al.CoverArtID()) + Expect(err).To(HaveOccurred()) + + Eventually(func(g Gomega) { + updated, err := ds.Album(ctx).Get(al.ID) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(updated.BlurHash).To(BeEmpty()) + }, "10s", "100ms").Should(Succeed()) + }) + It("does not persist a blurhash when the served image cannot be decoded", func() { setLayout(fstest.MapFS{ "Artist/Album/01 - Song.mp3": trackFile(1, "Song"), From 7a61776c1c14f7e7b66b1d9bca2757dfd9badd21 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 17 Jul 2026 13:53:08 -0400 Subject: [PATCH 20/46] fix(artwork): clear a fresh-looking hash when the serve finds no source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the window before a rescan records a deleted cover (or after cache eviction/restart), no row or mtime signal moves, so the freshness skip kept the stale hash. ErrUnavailable serves now carry a sourceGone signal that bypasses the skip only while a stored hash remains to clear — afterwards the negative cache applies, so artwork-less entities still don't re-resolve per serve. The e2e spec now covers the no-rescan window. --- core/artwork/artwork.go | 4 +-- core/artwork/blurhash_updater.go | 14 +++++++--- .../artwork/blurhash_updater_internal_test.go | 26 +++++++++++++++---- core/artwork/e2e/blurhash_test.go | 3 ++- 4 files changed, 35 insertions(+), 12 deletions(-) diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go index 826e3ab19..93e117168 100644 --- a/core/artwork/artwork.go +++ b/core/artwork/artwork.go @@ -83,7 +83,7 @@ func (a *artwork) Get(ctx context.Context, artID model.ArtworkID, size int, squa // A vanished source must still reach the worker, or a stored hash would keep describing // artwork that no longer exists. if a.blurHashes != nil && errors.Is(err, ErrUnavailable) { - a.blurHashes.Enqueue(artID, artReader.LastUpdated(), false) + a.blurHashes.Enqueue(artID, artReader.LastUpdated(), false, true) } return nil, time.Time{}, err } @@ -93,7 +93,7 @@ func (a *artwork) Get(ctx context.Context, artID model.ArtworkID, size int, squa // readers re-fetch the original through Get, carrying the real signal); nor does cache // warmup/disabled, where every serve misses — there the LastUpdated signal applies. force := size == 0 && !square && !r.Cached && a.cache.Available(ctx) - a.blurHashes.Enqueue(artID, artReader.LastUpdated(), force) + a.blurHashes.Enqueue(artID, artReader.LastUpdated(), force, false) } return r, artReader.LastUpdated(), nil } diff --git a/core/artwork/blurhash_updater.go b/core/artwork/blurhash_updater.go index d44797642..fa0a75624 100644 --- a/core/artwork/blurhash_updater.go +++ b/core/artwork/blurhash_updater.go @@ -17,10 +17,12 @@ import ( "github.com/navidrome/navidrome/resources" ) -// enqueueRequest carries the staleness signals seen at serve time: force (image-cache miss) and -// the reader's LastUpdated, which tracks file mtimes that no entity row timestamp reflects. +// enqueueRequest carries the staleness signals seen at serve time: force (image-cache miss), +// sourceGone (the serve failed with ErrUnavailable) and the reader's LastUpdated, which tracks +// file mtimes that no entity row timestamp reflects. type enqueueRequest struct { force bool + sourceGone bool imageUpdatedAt time.Time } @@ -59,7 +61,7 @@ func newBlurHashUpdater(a *artwork) *blurHashUpdater { } } -func (u *blurHashUpdater) Enqueue(artID model.ArtworkID, imageUpdatedAt time.Time, force bool) { +func (u *blurHashUpdater) Enqueue(artID model.ArtworkID, imageUpdatedAt time.Time, force, sourceGone bool) { switch artID.Kind { case model.KindAlbumArtwork, model.KindArtistArtwork, model.KindPlaylistArtwork: default: @@ -80,6 +82,7 @@ func (u *blurHashUpdater) Enqueue(artID model.ArtworkID, imageUpdatedAt time.Tim } req := u.buffer[artID] req.force = req.force || force + req.sourceGone = req.sourceGone || sourceGone if imageUpdatedAt.After(req.imageUpdatedAt) { req.imageUpdatedAt = imageUpdatedAt } @@ -167,7 +170,10 @@ func (u *blurHashUpdater) process(ctx context.Context, artID model.ArtworkID, re if req.imageUpdatedAt.After(sig) { sig = req.imageUpdatedAt } - if !req.force { + // A gone source only forces while a hash remains to clear; afterwards the negative cache + // applies, so artwork-less entities don't re-resolve on every placeholder serve. + force := req.force || (req.sourceGone && stored != "") + if !force { // Current when computed from this row version or later; the snapshot may exceed the row // version because file mtimes (which don't move rows) are folded into it on persist. if stored != "" && storedAt != nil && !storedAt.Before(version) && !sig.After(*storedAt) { diff --git a/core/artwork/blurhash_updater_internal_test.go b/core/artwork/blurhash_updater_internal_test.go index 1807ca0d1..69c75c0c3 100644 --- a/core/artwork/blurhash_updater_internal_test.go +++ b/core/artwork/blurhash_updater_internal_test.go @@ -38,17 +38,18 @@ var _ = Describe("blurHashUpdater", func() { id := model.Album{ID: "al-1"}.CoverArtID() t1 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) t2 := t1.Add(time.Hour) - u.Enqueue(id, t2, true) - u.Enqueue(id, t1, false) - u.Enqueue(model.Artist{ID: "ar-1"}.CoverArtID(), t1, false) + u.Enqueue(id, t2, true, false) + u.Enqueue(id, t1, false, true) + u.Enqueue(model.Artist{ID: "ar-1"}.CoverArtID(), t1, false, false) Expect(u.buffer).To(HaveLen(2)) Expect(u.buffer[id].force).To(BeTrue()) + Expect(u.buffer[id].sourceGone).To(BeTrue()) Expect(u.buffer[id].imageUpdatedAt).To(Equal(t2)) }) It("ignores other artwork kinds", func() { - u.Enqueue(model.ArtworkID{Kind: model.KindMediaFileArtwork, ID: "mf-1"}, time.Time{}, false) - u.Enqueue(model.ArtworkID{Kind: model.KindRadioArtwork, ID: "ra-1"}, time.Time{}, true) + u.Enqueue(model.ArtworkID{Kind: model.KindMediaFileArtwork, ID: "mf-1"}, time.Time{}, false, false) + u.Enqueue(model.ArtworkID{Kind: model.KindRadioArtwork, ID: "ra-1"}, time.Time{}, true, false) Expect(u.buffer).To(BeEmpty()) }) }) @@ -116,6 +117,21 @@ var _ = Describe("blurHashUpdater", func() { Expect(stored.BlurHash).To(BeEmpty()) }) + It("clears a fresh-looking stored hash when the source is gone", func() { + al := model.Album{ID: "al-1", UpdatedAt: version, BlurHash: "LEHV6nWB2yk8", BlurHashUpdatedAt: &version} + repo := tests.CreateMockAlbumRepo() + repo.SetData(model.Albums{al}) + ds.MockedAlbum = repo + ds.MockedFolder = failingFolderRepo{} + + // No row/mtime signal moved (eviction/restart window), but the serve 404ed: sourceGone + // must bypass the freshness skip so the stale hash is cleared. + u.process(GinkgoT().Context(), al.CoverArtID(), enqueueRequest{imageUpdatedAt: version, sourceGone: true}) + stored, err := ds.Album(GinkgoT().Context()).Get("al-1") + Expect(err).ToNot(HaveOccurred()) + Expect(stored.BlurHash).To(BeEmpty()) + }) + It("does nothing when the entity is gone", func() { ds.MockedAlbum = tests.CreateMockAlbumRepo() Expect(func() { diff --git a/core/artwork/e2e/blurhash_test.go b/core/artwork/e2e/blurhash_test.go index 90b759e6b..2bd9aaf18 100644 --- a/core/artwork/e2e/blurhash_test.go +++ b/core/artwork/e2e/blurhash_test.go @@ -49,10 +49,11 @@ var _ = Describe("BlurHash", func() { g.Expect(updated.BlurHash).ToNot(BeEmpty()) }, "10s", "100ms").Should(Succeed()) + // No rescan: the folder row still lists the cover, but the file is gone — the serve's + // ErrUnavailable alone must trigger the clear. setLayout(fstest.MapFS{ "Artist/Album/01 - Song.mp3": trackFile(1, "Song"), }) - scan() _, err := readArtworkOrErr(al.CoverArtID()) Expect(err).To(HaveOccurred()) From d0ac427377b3e9072e33392f7d33f3feb7f614a7 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 17 Jul 2026 14:08:44 -0400 Subject: [PATCH 21/46] ci: disable green-tea GC on the Windows test job Go 1.26's green-tea GC crashes intermittently on Windows runners: three distinct runtime fatal-error signatures across 1.26.4/1.26.5, always in the persistence suite, twice in a row on this PR. --- .github/workflows/pipeline.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 8e6e8126a..767cdd9a4 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -147,6 +147,9 @@ jobs: env: FFMPEG_VERSION: "7.1" FFMPEG_REPOSITORY: navidrome/ffmpeg-windows-builds + # Go 1.26's green-tea GC crashes intermittently on Windows runners (runtime fatal errors in + # the persistence suite, three distinct signatures across 1.26.4/1.26.5). + GOEXPERIMENT: nogreenteagc steps: - uses: actions/checkout@v7 From ae36e4dfc72299a58b0f9568bbda1dedb6603584 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 17 Jul 2026 14:13:09 -0400 Subject: [PATCH 22/46] fix(artwork): recompute on original serves when the image cache is disabled With ImageCacheSize=0 every serve reads live bytes, so an in-place cover swap without rescan changed the served image with no signal the worker could see. Original-size serves now force on disabled caches, and a new unchanged-hash guard skips the DB write, so the forced path costs only the background decode those installs already pay per request. --- core/artwork/artwork.go | 8 +++++--- core/artwork/blurhash_updater.go | 5 +++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go index 93e117168..0b9b8e3a4 100644 --- a/core/artwork/artwork.go +++ b/core/artwork/artwork.go @@ -90,9 +90,11 @@ func (a *artwork) Get(ctx context.Context, artID model.ArtworkID, size int, squa if a.blurHashes != nil { // An original-size miss on an operational cache means a new/changed image even when no // entity row moved. Resized misses don't qualify (their keys vary per size, and their - // readers re-fetch the original through Get, carrying the real signal); nor does cache - // warmup/disabled, where every serve misses — there the LastUpdated signal applies. - force := size == 0 && !square && !r.Cached && a.cache.Available(ctx) + // readers re-fetch the original through Get, carrying the real signal). With the cache + // permanently disabled every serve reads live bytes, so original serves always force + // (the worker's unchanged-hash guard keeps that write-free); warmup forces nothing. + force := size == 0 && !square && + ((!r.Cached && a.cache.Available(ctx)) || a.cache.Disabled(ctx)) a.blurHashes.Enqueue(artID, artReader.LastUpdated(), force, false) } return r, artReader.LastUpdated(), nil diff --git a/core/artwork/blurhash_updater.go b/core/artwork/blurhash_updater.go index fa0a75624..2ca56e56b 100644 --- a/core/artwork/blurhash_updater.go +++ b/core/artwork/blurhash_updater.go @@ -198,6 +198,11 @@ func (u *blurHashUpdater) process(ctx context.Context, artID model.ArtworkID, re } return } + // Unchanged hash with an unmoved signal needs no write — keeps forced recomputes (e.g. every + // original serve on cache-disabled installs) from hammering the DB. + if hash == stored && storedAt != nil && !sig.After(*storedAt) { + return + } if err := u.persist(ctx, artID, hash, sig); err != nil { log.Warn(ctx, "BlurHash: error persisting", "artID", artID, err) return From 3759488db5cf18c266fd78901210ae4dfa502f9f Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 17 Jul 2026 14:47:01 -0400 Subject: [PATCH 23/46] refactor(artwork): trigger blurhash recompute from the cache fill The blurhash worker previously recomputed on every artwork serve and reconciled a row-level freshness oracle against serve-time hints (force, sourceGone, imageUpdatedAt) to decide whether the served bytes had actually changed. Every staleness bug found in review was one case where that weak oracle disagreed with the true one, and each fix imported one more serve-time fragment into it. Move the trigger to the image-cache fill instead: an original-size cache miss is exactly when the served bytes change, so the reader's LastUpdated snapshot is the truth and no reconciliation is needed. Resized fills recurse through Get(size=0) and hash the original once; a disabled cache reports every original serve as a fill, keeping real hashes available (the worker's unchanged-hash guard keeps that write-free). This deletes the force/sourceGone/imageUpdatedAt flags, the double-freshness comparison, and the entire negative cache. Only the two irreducible pieces survive: the placeholder byte-compare and the ErrUnavailable clear-hook (EnqueueGone), since deletion-without-rescan is invisible to every passive signal. Adds an e2e test for in-place cover swaps, the scenario that drove the deleted machinery, now covered structurally by the fill trigger. Schema, DTO, repositories and the blurhash encoder package are unchanged. --- core/artwork/artwork.go | 16 +- core/artwork/blurhash_updater.go | 156 +++++++----------- .../artwork/blurhash_updater_internal_test.go | 97 +++++------ core/artwork/e2e/blurhash_test.go | 33 ++++ 4 files changed, 143 insertions(+), 159 deletions(-) diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go index 0b9b8e3a4..7b99d89ee 100644 --- a/core/artwork/artwork.go +++ b/core/artwork/artwork.go @@ -83,19 +83,15 @@ func (a *artwork) Get(ctx context.Context, artID model.ArtworkID, size int, squa // A vanished source must still reach the worker, or a stored hash would keep describing // artwork that no longer exists. if a.blurHashes != nil && errors.Is(err, ErrUnavailable) { - a.blurHashes.Enqueue(artID, artReader.LastUpdated(), false, true) + a.blurHashes.EnqueueGone(artID) } return nil, time.Time{}, err } - if a.blurHashes != nil { - // An original-size miss on an operational cache means a new/changed image even when no - // entity row moved. Resized misses don't qualify (their keys vary per size, and their - // readers re-fetch the original through Get, carrying the real signal). With the cache - // permanently disabled every serve reads live bytes, so original serves always force - // (the worker's unchanged-hash guard keeps that write-free); warmup forces nothing. - force := size == 0 && !square && - ((!r.Cached && a.cache.Available(ctx)) || a.cache.Disabled(ctx)) - a.blurHashes.Enqueue(artID, artReader.LastUpdated(), force, false) + if a.blurHashes != nil && size == 0 && !square && !r.Cached { + // An original-size cache fill is exactly when the served bytes change: the single recompute + // trigger. Resized fills recurse through Get(size=0); a disabled cache reports every serve as + // a fill (the worker's unchanged-hash guard keeps that write-free). + a.blurHashes.Enqueue(artID, artReader.LastUpdated()) } return r, artReader.LastUpdated(), nil } diff --git a/core/artwork/blurhash_updater.go b/core/artwork/blurhash_updater.go index 2ca56e56b..23a8799ee 100644 --- a/core/artwork/blurhash_updater.go +++ b/core/artwork/blurhash_updater.go @@ -17,22 +17,22 @@ import ( "github.com/navidrome/navidrome/resources" ) -// enqueueRequest carries the staleness signals seen at serve time: force (image-cache miss), -// sourceGone (the serve failed with ErrUnavailable) and the reader's LastUpdated, which tracks -// file mtimes that no entity row timestamp reflects. +// enqueueRequest carries the fill-time snapshot (the reader's LastUpdated, which folds row +// timestamps and live file mtimes into one clock). gone marks a serve that failed with +// ErrUnavailable: the only change no passive signal witnesses, so it clears a stale hash. type enqueueRequest struct { - force bool - sourceGone bool - imageUpdatedAt time.Time + snapshot time.Time + gone bool } -// blurHashUpdater keeps stored blurhashes in sync with the artwork actually served: Enqueue is a -// cheap dedup insert, and a single worker re-checks freshness before decoding. +// blurHashUpdater keeps stored blurhashes in sync with the artwork entering the image cache. +// Computation is triggered by cache fills (every change), not serves, so the worker only re-derives +// the hash and skips idempotent writes: Enqueue is a cheap dedup insert, a single worker decodes +// and persists. type blurHashUpdater struct { a *artwork mutex sync.Mutex buffer map[model.ArtworkID]enqueueRequest - noResult map[model.ArtworkID]noResultEntry wake chan struct{} done chan struct{} runDone chan struct{} @@ -41,27 +41,29 @@ type blurHashUpdater struct { stopped bool } -// noResultTTL bounds how long a failed or empty computation suppresses retries, so transient -// outages (agents, storage) self-heal despite being indistinguishable from "no artwork". -const noResultTTL = time.Hour - -type noResultEntry struct { - sig time.Time - at time.Time -} - func newBlurHashUpdater(a *artwork) *blurHashUpdater { return &blurHashUpdater{ - a: a, - buffer: make(map[model.ArtworkID]enqueueRequest), - noResult: make(map[model.ArtworkID]noResultEntry), - wake: make(chan struct{}, 1), - done: make(chan struct{}), - runDone: make(chan struct{}), + a: a, + buffer: make(map[model.ArtworkID]enqueueRequest), + wake: make(chan struct{}, 1), + done: make(chan struct{}), + runDone: make(chan struct{}), } } -func (u *blurHashUpdater) Enqueue(artID model.ArtworkID, imageUpdatedAt time.Time, force, sourceGone bool) { +// Enqueue schedules a recompute for an original-size cache fill, using the reader's snapshot as the +// artwork version. Called on the miss that fills the cache — i.e. exactly when the served bytes change. +func (u *blurHashUpdater) Enqueue(artID model.ArtworkID, snapshot time.Time) { + u.enqueue(artID, enqueueRequest{snapshot: snapshot}) +} + +// EnqueueGone schedules a clear for a serve that failed with ErrUnavailable, so a stored hash stops +// describing artwork that no longer exists (deletion is invisible to every passive signal). +func (u *blurHashUpdater) EnqueueGone(artID model.ArtworkID) { + u.enqueue(artID, enqueueRequest{gone: true}) +} + +func (u *blurHashUpdater) enqueue(artID model.ArtworkID, req enqueueRequest) { switch artID.Kind { case model.KindAlbumArtwork, model.KindArtistArtwork, model.KindPlaylistArtwork: default: @@ -80,13 +82,12 @@ func (u *blurHashUpdater) Enqueue(artID model.ArtworkID, imageUpdatedAt time.Tim u.runCancel = cancel go u.run(ctx) } - req := u.buffer[artID] - req.force = req.force || force - req.sourceGone = req.sourceGone || sourceGone - if imageUpdatedAt.After(req.imageUpdatedAt) { - req.imageUpdatedAt = imageUpdatedAt + prev := u.buffer[artID] + if req.snapshot.After(prev.snapshot) { + prev.snapshot = req.snapshot } - u.buffer[artID] = req + prev.gone = prev.gone || req.gone + u.buffer[artID] = prev u.mutex.Unlock() select { case u.wake <- struct{}{}: @@ -94,8 +95,8 @@ func (u *blurHashUpdater) Enqueue(artID model.ArtworkID, imageUpdatedAt time.Tim } } -// stop ends the worker and waits for any in-flight computation, so callers can safely tear down -// the resources (DataStore, filesystems) the worker touches. +// stop ends the worker and waits for any in-flight computation, so callers can safely tear down the +// resources (DataStore, filesystems) the worker touches. func (u *blurHashUpdater) stop() { u.mutex.Lock() if u.stopped { @@ -146,8 +147,8 @@ func (u *blurHashUpdater) next() (model.ArtworkID, enqueueRequest, bool) { return model.ArtworkID{}, enqueueRequest{}, false } -// processTimeout bounds one computation: readers can call external agents, and a hung call must -// not stall the worker forever. +// processTimeout bounds one computation: readers can call external agents, and a hung call must not +// stall the worker forever. const processTimeout = 30 * time.Second func (u *blurHashUpdater) process(ctx context.Context, artID model.ArtworkID, req enqueueRequest) { @@ -164,76 +165,41 @@ func (u *blurHashUpdater) process(ctx context.Context, artID model.ArtworkID, re log.Trace(ctx, "BlurHash: could not load entity", "artID", artID, err) return } - // sig is the newest staleness signal: the entity's artwork version or the served image's own - // timestamp, whichever is later (file swaps move the latter without touching any row). - sig := version - if req.imageUpdatedAt.After(sig) { - sig = req.imageUpdatedAt - } - // A gone source only forces while a hash remains to clear; afterwards the negative cache - // applies, so artwork-less entities don't re-resolve on every placeholder serve. - force := req.force || (req.sourceGone && stored != "") - if !force { - // Current when computed from this row version or later; the snapshot may exceed the row - // version because file mtimes (which don't move rows) are folded into it on persist. - if stored != "" && storedAt != nil && !storedAt.Before(version) && !sig.After(*storedAt) { - return - } - if last, ok := u.lastNoResult(artID); ok && !sig.After(last.sig) && time.Since(last.at) < noResultTTL { - return - } - } - hash, err := u.computeFromArtwork(ctx, artID) - if err != nil || hash == "" { - // Any no-result (no artwork, placeholder, decode failure, transient outage) is memoized - // with a TTL: browsing stays cheap, and failures still retry once it expires. - log.Trace(ctx, "BlurHash: nothing to persist", "artID", artID, err) - u.setNoResult(artID, sig) - // Reaching compute with a stored hash means there was change evidence — clear it, so the - // DTO falls back to the rotating fake instead of describing artwork no longer served. + if req.gone { + // Only the failed serve witnesses a deletion; clear a stored hash so the DTO falls back to + // the rotating fake. An empty hash means there is nothing to clear. if stored != "" { - if err := u.persist(ctx, artID, "", sig); err != nil { + if err := u.persist(ctx, artID, "", version); err != nil { log.Warn(ctx, "BlurHash: error clearing stale hash", "artID", artID, err) } } return } - // Unchanged hash with an unmoved signal needs no write — keeps forced recomputes (e.g. every - // original serve on cache-disabled installs) from hammering the DB. - if hash == stored && storedAt != nil && !sig.After(*storedAt) { + // snapshot folds row timestamps and file mtimes into one clock; a stored hash at or after it is + // already current. This is the only freshness comparison the fill trigger needs. + if stored != "" && storedAt != nil && !storedAt.Before(req.snapshot) { return } - if err := u.persist(ctx, artID, hash, sig); err != nil { + hash, err := u.computeFromArtwork(ctx, artID) + if err != nil || hash == "" { + log.Trace(ctx, "BlurHash: nothing to persist", "artID", artID, err) + // Reaching compute with a stored hash means the cover became a placeholder or vanished; + // clear it so the DTO stops describing artwork no longer served. + if stored != "" { + if err := u.persist(ctx, artID, "", req.snapshot); err != nil { + log.Warn(ctx, "BlurHash: error clearing stale hash", "artID", artID, err) + } + } + return + } + // Unchanged hash with an unmoved snapshot needs no write — keeps cache-disabled installs (which + // fill on every original serve) from hammering the DB. + if hash == stored && storedAt != nil && !req.snapshot.After(*storedAt) { + return + } + if err := u.persist(ctx, artID, hash, req.snapshot); err != nil { log.Warn(ctx, "BlurHash: error persisting", "artID", artID, err) - return } - u.clearNoResult(artID) -} - -func (u *blurHashUpdater) lastNoResult(artID model.ArtworkID) (noResultEntry, bool) { - u.mutex.Lock() - defer u.mutex.Unlock() - e, ok := u.noResult[artID] - return e, ok -} - -// maxNoResultEntries bounds the negative cache; entries only accumulate for artwork-less entities, -// so a wholesale reset just costs those entities one extra verification pass each. -const maxNoResultEntries = 25_000 - -func (u *blurHashUpdater) setNoResult(artID model.ArtworkID, sig time.Time) { - u.mutex.Lock() - defer u.mutex.Unlock() - if len(u.noResult) >= maxNoResultEntries { - clear(u.noResult) - } - u.noResult[artID] = noResultEntry{sig: sig, at: time.Now()} -} - -func (u *blurHashUpdater) clearNoResult(artID model.ArtworkID) { - u.mutex.Lock() - defer u.mutex.Unlock() - delete(u.noResult, artID) } func (u *blurHashUpdater) loadState(ctx context.Context, artID model.ArtworkID) (string, *time.Time, time.Time, error) { diff --git a/core/artwork/blurhash_updater_internal_test.go b/core/artwork/blurhash_updater_internal_test.go index 69c75c0c3..8d020e37b 100644 --- a/core/artwork/blurhash_updater_internal_test.go +++ b/core/artwork/blurhash_updater_internal_test.go @@ -25,31 +25,38 @@ var _ = Describe("blurHashUpdater", func() { ds = &tests.MockDataStore{} // started is pre-set so Enqueue never spawns run(): tests drive next()/process() directly. u = &blurHashUpdater{ - a: &artwork{ds: ds}, - buffer: make(map[model.ArtworkID]enqueueRequest), - noResult: make(map[model.ArtworkID]noResultEntry), - wake: make(chan struct{}, 1), - started: true, + a: &artwork{ds: ds}, + buffer: make(map[model.ArtworkID]enqueueRequest), + wake: make(chan struct{}, 1), + started: true, } }) Describe("Enqueue", func() { - It("accepts album, artist and playlist artwork and dedups, merging force and newest image time", func() { + It("accepts album, artist and playlist artwork and dedups, keeping the newest snapshot", func() { id := model.Album{ID: "al-1"}.CoverArtID() t1 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) t2 := t1.Add(time.Hour) - u.Enqueue(id, t2, true, false) - u.Enqueue(id, t1, false, true) - u.Enqueue(model.Artist{ID: "ar-1"}.CoverArtID(), t1, false, false) + u.Enqueue(id, t1) + u.Enqueue(id, t2) + u.Enqueue(model.Artist{ID: "ar-1"}.CoverArtID(), t1) Expect(u.buffer).To(HaveLen(2)) - Expect(u.buffer[id].force).To(BeTrue()) - Expect(u.buffer[id].sourceGone).To(BeTrue()) - Expect(u.buffer[id].imageUpdatedAt).To(Equal(t2)) + Expect(u.buffer[id].snapshot).To(Equal(t2)) + Expect(u.buffer[id].gone).To(BeFalse()) + }) + + It("merges a gone flag onto a pending snapshot for the same artwork", func() { + id := model.Album{ID: "al-1"}.CoverArtID() + t1 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + u.Enqueue(id, t1) + u.EnqueueGone(id) + Expect(u.buffer[id].snapshot).To(Equal(t1)) + Expect(u.buffer[id].gone).To(BeTrue()) }) It("ignores other artwork kinds", func() { - u.Enqueue(model.ArtworkID{Kind: model.KindMediaFileArtwork, ID: "mf-1"}, time.Time{}, false, false) - u.Enqueue(model.ArtworkID{Kind: model.KindRadioArtwork, ID: "ra-1"}, time.Time{}, true, false) + u.Enqueue(model.ArtworkID{Kind: model.KindMediaFileArtwork, ID: "mf-1"}, time.Time{}) + u.EnqueueGone(model.ArtworkID{Kind: model.KindRadioArtwork, ID: "ra-1"}) Expect(u.buffer).To(BeEmpty()) }) }) @@ -61,77 +68,59 @@ var _ = Describe("blurHashUpdater", func() { version = time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) }) - It("skips entities whose stored hash matches the current artwork version", func() { + It("skips entities whose stored hash is at or after the snapshot", func() { al := model.Album{ID: "al-1", UpdatedAt: version, BlurHash: "LEHV6nWB2yk8", BlurHashUpdatedAt: &version} repo := tests.CreateMockAlbumRepo() repo.SetData(model.Albums{al}) ds.MockedAlbum = repo - u.process(GinkgoT().Context(), al.CoverArtID(), enqueueRequest{imageUpdatedAt: version}) + u.process(GinkgoT().Context(), al.CoverArtID(), enqueueRequest{snapshot: version}) stored, err := ds.Album(GinkgoT().Context()).Get("al-1") Expect(err).ToNot(HaveOccurred()) Expect(stored.BlurHash).To(Equal("LEHV6nWB2yk8")) }) - It("skips entities that previously yielded no result for the same signals", func() { - al := model.Album{ID: "al-1", UpdatedAt: version} - repo := tests.CreateMockAlbumRepo() - repo.SetData(model.Albums{al}) - ds.MockedAlbum = repo - u.setNoResult(al.CoverArtID(), version) - - u.process(GinkgoT().Context(), al.CoverArtID(), enqueueRequest{imageUpdatedAt: version}) - stored, _ := ds.Album(GinkgoT().Context()).Get("al-1") - Expect(stored.BlurHash).To(BeEmpty()) - }) - - It("memoizes a failed retry under the newer signal", func() { - al := model.Album{ID: "al-1", UpdatedAt: version} + It("clears a stored hash when a recompute yields no result", func() { + al := model.Album{ID: "al-1", UpdatedAt: version, BlurHash: "LEHV6nWB2yk8", BlurHashUpdatedAt: nil} repo := tests.CreateMockAlbumRepo() repo.SetData(model.Albums{al}) ds.MockedAlbum = repo ds.MockedFolder = failingFolderRepo{} - u.setNoResult(al.CoverArtID(), version) - // A newer image mtime bypasses the no-result skip; the compute fails cleanly here, so - // the entry is refreshed under the newer signal, with the TTL as the retry bound. + // A stored hash with a newer snapshot is change evidence; the compute fails, so the + // stale hash must go. newer := version.Add(time.Hour) - u.process(GinkgoT().Context(), al.CoverArtID(), enqueueRequest{imageUpdatedAt: newer}) - last, ok := u.lastNoResult(al.CoverArtID()) - Expect(ok).To(BeTrue()) - Expect(last.sig).To(Equal(newer)) - }) - - It("clears a stored hash when a recompute with change evidence yields no result", func() { - storedAt := version.Add(-time.Hour) - al := model.Album{ID: "al-1", UpdatedAt: version, BlurHash: "LEHV6nWB2yk8", BlurHashUpdatedAt: &storedAt} - repo := tests.CreateMockAlbumRepo() - repo.SetData(model.Albums{al}) - ds.MockedAlbum = repo - ds.MockedFolder = failingFolderRepo{} - - // storedAt < version = change evidence; the compute fails, so the stale hash must go. - u.process(GinkgoT().Context(), al.CoverArtID(), enqueueRequest{imageUpdatedAt: version}) + u.process(GinkgoT().Context(), al.CoverArtID(), enqueueRequest{snapshot: newer}) stored, err := ds.Album(GinkgoT().Context()).Get("al-1") Expect(err).ToNot(HaveOccurred()) Expect(stored.BlurHash).To(BeEmpty()) }) - It("clears a fresh-looking stored hash when the source is gone", func() { + It("clears a stored hash when the source is gone", func() { al := model.Album{ID: "al-1", UpdatedAt: version, BlurHash: "LEHV6nWB2yk8", BlurHashUpdatedAt: &version} repo := tests.CreateMockAlbumRepo() repo.SetData(model.Albums{al}) ds.MockedAlbum = repo - ds.MockedFolder = failingFolderRepo{} - // No row/mtime signal moved (eviction/restart window), but the serve 404ed: sourceGone - // must bypass the freshness skip so the stale hash is cleared. - u.process(GinkgoT().Context(), al.CoverArtID(), enqueueRequest{imageUpdatedAt: version, sourceGone: true}) + u.process(GinkgoT().Context(), al.CoverArtID(), enqueueRequest{gone: true}) stored, err := ds.Album(GinkgoT().Context()).Get("al-1") Expect(err).ToNot(HaveOccurred()) Expect(stored.BlurHash).To(BeEmpty()) }) + It("does nothing for a gone serve with no stored hash", func() { + al := model.Album{ID: "al-1", UpdatedAt: version} + repo := tests.CreateMockAlbumRepo() + repo.SetData(model.Albums{al}) + ds.MockedAlbum = repo + + Expect(func() { + u.process(GinkgoT().Context(), al.CoverArtID(), enqueueRequest{gone: true}) + }).ToNot(Panic()) + stored, _ := ds.Album(GinkgoT().Context()).Get("al-1") + Expect(stored.BlurHash).To(BeEmpty()) + }) + It("does nothing when the entity is gone", func() { ds.MockedAlbum = tests.CreateMockAlbumRepo() Expect(func() { diff --git a/core/artwork/e2e/blurhash_test.go b/core/artwork/e2e/blurhash_test.go index 2bd9aaf18..e92efee86 100644 --- a/core/artwork/e2e/blurhash_test.go +++ b/core/artwork/e2e/blurhash_test.go @@ -35,6 +35,39 @@ var _ = Describe("BlurHash", func() { }, "10s", "100ms").Should(Succeed()) }) + It("recomputes when the cover is swapped in place", func() { + setLayout(fstest.MapFS{ + "Artist/Album/01 - Song.mp3": trackFile(1, "Song"), + "Artist/Album/cover.png": realPNG("original-cover"), + }) + scan() + al := firstAlbum() + readArtwork(al.CoverArtID()) + var firstHash string + Eventually(func(g Gomega) { + updated, err := ds.Album(ctx).Get(al.ID) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(updated.BlurHash).ToNot(BeEmpty()) + firstHash = updated.BlurHash + }, "10s", "100ms").Should(Succeed()) + + // Swap the cover bytes and rescan: the folder's image version moves, so the reader key moves, + // the cache misses and the fill re-triggers the compute — no serve-time force hint needed. + setLayout(fstest.MapFS{ + "Artist/Album/01 - Song.mp3": trackFile(1, "Song"), + "Artist/Album/cover.png": realPNG("swapped-cover"), + }) + scan() + readArtwork(al.CoverArtID()) + + Eventually(func(g Gomega) { + updated, err := ds.Album(ctx).Get(al.ID) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(updated.BlurHash).ToNot(BeEmpty()) + g.Expect(updated.BlurHash).ToNot(Equal(firstHash)) + }, "10s", "100ms").Should(Succeed()) + }) + It("clears the stored blurhash when the cover disappears", func() { setLayout(fstest.MapFS{ "Artist/Album/01 - Song.mp3": trackFile(1, "Song"), From 66d6fdc1a64cf3659e936928bfe72e19d2ba73bb Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 17 Jul 2026 15:03:19 -0400 Subject: [PATCH 24/46] test(artwork): stop the blurhash worker before spec TempDir cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The artwork e2e suite stopped the worker via a DeferCleanup registered in setupHarness's BeforeEach, which Ginkgo runs LAST — after a spec body's own GinkgoT().TempDir() cleanups. With the cache disabled in these tests, every artwork serve now enqueues a blurhash recompute, so the worker can still be reading a spec-local sidecar file when its TempDir is removed. On Windows that unlink fails ("the process cannot access the file because it is being used by another process"), which flaked the playlist case-insensitive sidecar spec. Move the worker shutdown to a suite-level AfterEach, which runs before any spec-body DeferCleanup, so no artwork file is held open when TempDir removal runs. Close() is idempotent, so the change is safe across specs. --- core/artwork/e2e/suite_test.go | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/core/artwork/e2e/suite_test.go b/core/artwork/e2e/suite_test.go index 1ca4f2e2a..81bec7f52 100644 --- a/core/artwork/e2e/suite_test.go +++ b/core/artwork/e2e/suite_test.go @@ -58,6 +58,18 @@ var _ = AfterSuite(func() { db.Close(GinkgoT().Context()) }) +// AfterEach runs before any DeferCleanup a spec body registered, so it stops the blurhash worker +// before spec-local TempDirs are removed. On Windows a still-running worker can hold an artwork +// file open, and TempDir removal cannot unlink an open file. +var _ = AfterEach(func() { + if aw == nil { + return + } + if c, ok := aw.(io.Closer); ok { + Expect(c.Close()).To(Succeed()) + } +}) + func setupHarness() { DeferCleanup(configtest.SetupConfig()) @@ -89,8 +101,8 @@ func setupHarness() { storagetest.Register(fakeLibScheme, fakeFS) aw = artwork.NewArtwork(ds, artwork.GetImageCache(), newNoopFFmpeg(), &noopProvider{}) - // The worker must not outlive the spec: it would race the next spec's fakeFS/DB swaps. - DeferCleanup(aw.(io.Closer).Close) + // The worker is stopped by the suite-level AfterEach (which runs before spec-local TempDir + // cleanups), so it can't outlive the spec or hold a file open past TempDir removal. } func scan() { From 2cd967a4562dab6ac659f05a659574d87172b154 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 17 Jul 2026 15:18:28 -0400 Subject: [PATCH 25/46] fix(artwork): keep the stored blurhash on transient recompute errors process() cleared the stored hash whenever computeFromArtwork returned an error OR an empty hash. A transient failure (the 30s context timeout, a flaky cache/DB/reader read) is not evidence the artwork changed, so clearing on it made the Jellyfin DTO fall back to a fake blurhash and churn clients' cover caches until a later successful fill restored it. Split the two outcomes: a compute error now leaves the stored hash intact and lets a later fill retry, while an empty hash (a placeholder, i.e. the cover is confirmed gone) still clears it. Deletion witnessed by a failed serve is already handled separately by the gone path. --- core/artwork/blurhash_updater.go | 13 +++++++++---- core/artwork/blurhash_updater_internal_test.go | 8 ++++---- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/core/artwork/blurhash_updater.go b/core/artwork/blurhash_updater.go index 23a8799ee..eb2f9f40a 100644 --- a/core/artwork/blurhash_updater.go +++ b/core/artwork/blurhash_updater.go @@ -181,10 +181,15 @@ func (u *blurHashUpdater) process(ctx context.Context, artID model.ArtworkID, re return } hash, err := u.computeFromArtwork(ctx, artID) - if err != nil || hash == "" { - log.Trace(ctx, "BlurHash: nothing to persist", "artID", artID, err) - // Reaching compute with a stored hash means the cover became a placeholder or vanished; - // clear it so the DTO stops describing artwork no longer served. + if err != nil { + // A transient failure (timeout, flaky cache/DB read) is not evidence the artwork changed; + // leave the stored hash intact and let a later fill retry, so clients don't churn on a fake. + log.Trace(ctx, "BlurHash: recompute failed, keeping stored hash", "artID", artID, err) + return + } + if hash == "" { + // An empty hash means the served bytes are a placeholder: the cover is gone. Clear a stored + // hash so the DTO stops describing artwork no longer served. if stored != "" { if err := u.persist(ctx, artID, "", req.snapshot); err != nil { log.Warn(ctx, "BlurHash: error clearing stale hash", "artID", artID, err) diff --git a/core/artwork/blurhash_updater_internal_test.go b/core/artwork/blurhash_updater_internal_test.go index 8d020e37b..d2e5960bb 100644 --- a/core/artwork/blurhash_updater_internal_test.go +++ b/core/artwork/blurhash_updater_internal_test.go @@ -80,20 +80,20 @@ var _ = Describe("blurHashUpdater", func() { Expect(stored.BlurHash).To(Equal("LEHV6nWB2yk8")) }) - It("clears a stored hash when a recompute yields no result", func() { + It("keeps the stored hash when a recompute fails transiently", func() { al := model.Album{ID: "al-1", UpdatedAt: version, BlurHash: "LEHV6nWB2yk8", BlurHashUpdatedAt: nil} repo := tests.CreateMockAlbumRepo() repo.SetData(model.Albums{al}) ds.MockedAlbum = repo ds.MockedFolder = failingFolderRepo{} - // A stored hash with a newer snapshot is change evidence; the compute fails, so the - // stale hash must go. + // A newer snapshot forces a recompute, but the reader chain errors (transient): the stored + // hash must survive, so clients don't churn on a fake until a later fill succeeds. newer := version.Add(time.Hour) u.process(GinkgoT().Context(), al.CoverArtID(), enqueueRequest{snapshot: newer}) stored, err := ds.Album(GinkgoT().Context()).Get("al-1") Expect(err).ToNot(HaveOccurred()) - Expect(stored.BlurHash).To(BeEmpty()) + Expect(stored.BlurHash).To(Equal("LEHV6nWB2yk8")) }) It("clears a stored hash when the source is gone", func() { From 5cd75503cbe8badd4eced1bf3471572235e04ec9 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 17 Jul 2026 15:36:17 -0400 Subject: [PATCH 26/46] fix(artwork): backfill warm caches and supersede pending gone on refill Two issues in the fill-triggered blurhash recompute: 1. Enqueuing was gated on a cache miss (!r.Cached). On an upgraded instance the image cache is persisted and adopted across restarts, so already-cached artwork serves as a cache hit and never enqueued, leaving its migrated-empty blur_hash as a synthetic value indefinitely. Enqueue on every original-size serve instead: the worker's freshness guard turns already-hashed rows into a cheap read and only recomputes when the hash is stale or missing. 2. Enqueue merged the gone flag with a sticky OR, so a cover restored right after a missing-art serve kept gone=true and took the clear-and-return path, never recomputing. A successful serve proves the artwork exists, so it now clears any pending gone for that artwork; a gone that follows a fill still sticks. --- core/artwork/artwork.go | 9 +++++---- core/artwork/blurhash_updater.go | 12 +++++++++--- core/artwork/blurhash_updater_internal_test.go | 11 ++++++++++- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go index 7b99d89ee..f3dcb7593 100644 --- a/core/artwork/artwork.go +++ b/core/artwork/artwork.go @@ -87,10 +87,11 @@ func (a *artwork) Get(ctx context.Context, artID model.ArtworkID, size int, squa } return nil, time.Time{}, err } - if a.blurHashes != nil && size == 0 && !square && !r.Cached { - // An original-size cache fill is exactly when the served bytes change: the single recompute - // trigger. Resized fills recurse through Get(size=0); a disabled cache reports every serve as - // a fill (the worker's unchanged-hash guard keeps that write-free). + if a.blurHashes != nil && size == 0 && !square { + // Every original-size serve carries the reader's true version; the worker's freshness guard + // turns already-hashed rows into a cheap read and only recomputes when the hash is stale or + // missing. Enqueuing on cache hits too (not just fills) is what backfills warm caches adopted + // from a pre-blurhash version, whose rows migrated in with an empty hash. a.blurHashes.Enqueue(artID, artReader.LastUpdated()) } return r, artReader.LastUpdated(), nil diff --git a/core/artwork/blurhash_updater.go b/core/artwork/blurhash_updater.go index eb2f9f40a..37a39fdd9 100644 --- a/core/artwork/blurhash_updater.go +++ b/core/artwork/blurhash_updater.go @@ -83,10 +83,16 @@ func (u *blurHashUpdater) enqueue(artID model.ArtworkID, req enqueueRequest) { go u.run(ctx) } prev := u.buffer[artID] - if req.snapshot.After(prev.snapshot) { - prev.snapshot = req.snapshot + if !req.snapshot.IsZero() { + // A successful serve proves the artwork exists, so it supersedes any pending gone request for + // the same artwork (a cover restored right after a missing-art serve must still recompute). + prev.gone = false + if req.snapshot.After(prev.snapshot) { + prev.snapshot = req.snapshot + } + } else if req.gone { + prev.gone = true } - prev.gone = prev.gone || req.gone u.buffer[artID] = prev u.mutex.Unlock() select { diff --git a/core/artwork/blurhash_updater_internal_test.go b/core/artwork/blurhash_updater_internal_test.go index d2e5960bb..8a27a2c9e 100644 --- a/core/artwork/blurhash_updater_internal_test.go +++ b/core/artwork/blurhash_updater_internal_test.go @@ -45,7 +45,7 @@ var _ = Describe("blurHashUpdater", func() { Expect(u.buffer[id].gone).To(BeFalse()) }) - It("merges a gone flag onto a pending snapshot for the same artwork", func() { + It("keeps a gone flag when it follows a pending fill (cover then vanished)", func() { id := model.Album{ID: "al-1"}.CoverArtID() t1 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) u.Enqueue(id, t1) @@ -54,6 +54,15 @@ var _ = Describe("blurHashUpdater", func() { Expect(u.buffer[id].gone).To(BeTrue()) }) + It("lets a successful fill supersede a pending gone (cover restored)", func() { + id := model.Album{ID: "al-1"}.CoverArtID() + t1 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + u.EnqueueGone(id) + u.Enqueue(id, t1) + Expect(u.buffer[id].snapshot).To(Equal(t1)) + Expect(u.buffer[id].gone).To(BeFalse()) + }) + It("ignores other artwork kinds", func() { u.Enqueue(model.ArtworkID{Kind: model.KindMediaFileArtwork, ID: "mf-1"}, time.Time{}) u.EnqueueGone(model.ArtworkID{Kind: model.KindRadioArtwork, ID: "ra-1"}) From bf7ae5e82e44c2d8cd1c807295a7dfb9d12df421 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 17 Jul 2026 15:51:01 -0400 Subject: [PATCH 27/46] fix(artwork): cap the blurhash snapshot at now to survive future mtimes A future-dated artwork file mtime (clock skew, or a file stamped ahead of the server clock) flowed into the reader's LastUpdated snapshot and was persisted verbatim as blur_hash_updated_at. Both the worker's freshness guard and the Jellyfin DTO use a !Before comparison against that timestamp, so a later legitimate cover change whose row/mtime clock was still behind the future value would be skipped, pinning the stored hash (and the client's cached cover) until wall time caught up. Cap the snapshot at time.Now() in process() before every freshness comparison and persist, so a real later change always advances past it. Normal past mtimes (the legitimate 'snapshot exceeds row version' case) are unaffected. --- core/artwork/blurhash_updater.go | 16 ++++++++++++---- core/artwork/e2e/blurhash_test.go | 23 +++++++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/core/artwork/blurhash_updater.go b/core/artwork/blurhash_updater.go index 37a39fdd9..224e2e087 100644 --- a/core/artwork/blurhash_updater.go +++ b/core/artwork/blurhash_updater.go @@ -171,6 +171,14 @@ func (u *blurHashUpdater) process(ctx context.Context, artID model.ArtworkID, re log.Trace(ctx, "BlurHash: could not load entity", "artID", artID, err) return } + // A future-dated artwork file mtime (clock skew, a future-stamped file) would otherwise be + // persisted verbatim and, via the !Before checks here and in the DTO, pin the stored hash until + // wall time caught up. Cap the snapshot at now so a later real change always moves past it. + now := time.Now() + snapshot := req.snapshot + if snapshot.After(now) { + snapshot = now + } if req.gone { // Only the failed serve witnesses a deletion; clear a stored hash so the DTO falls back to // the rotating fake. An empty hash means there is nothing to clear. @@ -183,7 +191,7 @@ func (u *blurHashUpdater) process(ctx context.Context, artID model.ArtworkID, re } // snapshot folds row timestamps and file mtimes into one clock; a stored hash at or after it is // already current. This is the only freshness comparison the fill trigger needs. - if stored != "" && storedAt != nil && !storedAt.Before(req.snapshot) { + if stored != "" && storedAt != nil && !storedAt.Before(snapshot) { return } hash, err := u.computeFromArtwork(ctx, artID) @@ -197,7 +205,7 @@ func (u *blurHashUpdater) process(ctx context.Context, artID model.ArtworkID, re // An empty hash means the served bytes are a placeholder: the cover is gone. Clear a stored // hash so the DTO stops describing artwork no longer served. if stored != "" { - if err := u.persist(ctx, artID, "", req.snapshot); err != nil { + if err := u.persist(ctx, artID, "", snapshot); err != nil { log.Warn(ctx, "BlurHash: error clearing stale hash", "artID", artID, err) } } @@ -205,10 +213,10 @@ func (u *blurHashUpdater) process(ctx context.Context, artID model.ArtworkID, re } // Unchanged hash with an unmoved snapshot needs no write — keeps cache-disabled installs (which // fill on every original serve) from hammering the DB. - if hash == stored && storedAt != nil && !req.snapshot.After(*storedAt) { + if hash == stored && storedAt != nil && !snapshot.After(*storedAt) { return } - if err := u.persist(ctx, artID, hash, req.snapshot); err != nil { + if err := u.persist(ctx, artID, hash, snapshot); err != nil { log.Warn(ctx, "BlurHash: error persisting", "artID", artID, err) } } diff --git a/core/artwork/e2e/blurhash_test.go b/core/artwork/e2e/blurhash_test.go index e92efee86..2dbf8da00 100644 --- a/core/artwork/e2e/blurhash_test.go +++ b/core/artwork/e2e/blurhash_test.go @@ -2,6 +2,7 @@ package artworke2e_test import ( "testing/fstest" + "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -35,6 +36,28 @@ var _ = Describe("BlurHash", func() { }, "10s", "100ms").Should(Succeed()) }) + It("does not persist a future-dated blurhash timestamp", func() { + cover := realPNG("future-cover") + cover.ModTime = time.Now().Add(500 * time.Hour) // clock skew / future-stamped file + setLayout(fstest.MapFS{ + "Artist/Album/01 - Song.mp3": trackFile(1, "Song"), + "Artist/Album/cover.png": cover, + }) + scan() + al := firstAlbum() + readArtwork(al.CoverArtID()) + + Eventually(func(g Gomega) { + updated, err := ds.Album(ctx).Get(al.ID) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(updated.BlurHash).ToNot(BeEmpty()) + g.Expect(updated.BlurHashUpdatedAt).ToNot(BeNil()) + // A future file mtime must be capped at now, or the !Before checks would pin the hash + // (and the client's cover cache) until wall time caught up. + g.Expect(updated.BlurHashUpdatedAt.After(time.Now())).To(BeFalse()) + }, "10s", "100ms").Should(Succeed()) + }) + It("recomputes when the cover is swapped in place", func() { setLayout(fstest.MapFS{ "Artist/Album/01 - Song.mp3": trackFile(1, "Song"), From eb5ecabc5ac9635a864f631947c2f8da3e09fed7 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 17 Jul 2026 19:35:13 -0400 Subject: [PATCH 28/46] test(artwork): characterize mtime-preserved cover swap staleness (pending) A cover replaced in place without a mtime change is not re-hashed today: the freshness guard skips recompute when no timestamp moved, so the stored blurhash (and the client's cover cache keyed by it) stays stale. Marked pending until the served-bytes tee lands, which recomputes from the exact bytes served. --- core/artwork/e2e/blurhash_test.go | 35 +++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/core/artwork/e2e/blurhash_test.go b/core/artwork/e2e/blurhash_test.go index 2dbf8da00..148a3ff56 100644 --- a/core/artwork/e2e/blurhash_test.go +++ b/core/artwork/e2e/blurhash_test.go @@ -120,6 +120,41 @@ var _ = Describe("BlurHash", func() { }, "10s", "100ms").Should(Succeed()) }) + PIt("recomputes when cover bytes change under a preserved mtime (cache disabled)", func() { + cover := realPNG("orig-bytes") + fixed := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + cover.ModTime = fixed + setLayout(fstest.MapFS{ + "Artist/Album/01 - Song.mp3": trackFile(1, "Song"), + "Artist/Album/cover.png": cover, + }) + scan() + al := firstAlbum() + readArtwork(al.CoverArtID()) + var firstHash string + Eventually(func(g Gomega) { + updated, err := ds.Album(ctx).Get(al.ID) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(updated.BlurHash).ToNot(BeEmpty()) + firstHash = updated.BlurHash + }, "10s", "100ms").Should(Succeed()) + + // Replace the bytes but keep the SAME mtime and do NOT rescan: only the served bytes change. + swapped := realPNG("swapped-bytes") + swapped.ModTime = fixed + setLayout(fstest.MapFS{ + "Artist/Album/01 - Song.mp3": trackFile(1, "Song"), + "Artist/Album/cover.png": swapped, + }) + readArtwork(al.CoverArtID()) + + Eventually(func(g Gomega) { + updated, err := ds.Album(ctx).Get(al.ID) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(updated.BlurHash).ToNot(Equal(firstHash)) + }, "10s", "100ms").Should(Succeed()) + }) + It("does not persist a blurhash when the served image cannot be decoded", func() { setLayout(fstest.MapFS{ "Artist/Album/01 - Song.mp3": trackFile(1, "Song"), From 2169938b30f15d8fc84c403b80e52f3c5547b0e7 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 17 Jul 2026 19:36:31 -0400 Subject: [PATCH 29/46] feat(artwork): add teeReader to capture served artwork bytes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A wrapping io.ReadCloser that mirrors read bytes into a bounded buffer and, on a fully-consumed Close, hands the captured bytes to a callback. Partial reads and oversized streams are skipped so the callback only ever receives a complete, bounded image — the exact bytes the client received. This is the capture side of the served-bytes blurhash tee. --- core/artwork/blurhash_tee.go | 48 ++++++++++++++++++++++ core/artwork/blurhash_tee_internal_test.go | 43 +++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 core/artwork/blurhash_tee.go create mode 100644 core/artwork/blurhash_tee_internal_test.go diff --git a/core/artwork/blurhash_tee.go b/core/artwork/blurhash_tee.go new file mode 100644 index 000000000..f603f1f65 --- /dev/null +++ b/core/artwork/blurhash_tee.go @@ -0,0 +1,48 @@ +package artwork + +import ( + "bytes" + "io" +) + +// teeReader mirrors bytes read from src into buf, and on Close invokes onComplete with the captured +// bytes only if the stream was fully consumed (EOF) and stayed within maxBytes. Partial reads and +// oversized streams are skipped, so a hash is only ever computed from a complete, bounded image. +type teeReader struct { + src io.ReadCloser + buf bytes.Buffer + maxBytes int + onComplete func(data []byte) + eof bool + over bool + done bool +} + +func newTeeReader(src io.ReadCloser, maxBytes int, onComplete func(data []byte)) *teeReader { + return &teeReader{src: src, maxBytes: maxBytes, onComplete: onComplete} +} + +func (t *teeReader) Read(p []byte) (int, error) { + n, err := t.src.Read(p) + if n > 0 && !t.over { + if t.buf.Len()+n > t.maxBytes { + t.over = true + t.buf.Reset() + } else { + t.buf.Write(p[:n]) + } + } + if err == io.EOF { + t.eof = true + } + return n, err +} + +func (t *teeReader) Close() error { + err := t.src.Close() + if !t.done && t.eof && !t.over && t.onComplete != nil { + t.done = true + t.onComplete(t.buf.Bytes()) + } + return err +} diff --git a/core/artwork/blurhash_tee_internal_test.go b/core/artwork/blurhash_tee_internal_test.go new file mode 100644 index 000000000..595bbb099 --- /dev/null +++ b/core/artwork/blurhash_tee_internal_test.go @@ -0,0 +1,43 @@ +package artwork + +import ( + "bytes" + "io" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("teeReader", func() { + It("calls onComplete with the full bytes after a complete read+close", func() { + var got []byte + src := io.NopCloser(bytes.NewReader([]byte("hello world"))) + tr := newTeeReader(src, 1024, func(data []byte) { got = data }) + out, err := io.ReadAll(tr) + Expect(err).ToNot(HaveOccurred()) + Expect(string(out)).To(Equal("hello world")) + Expect(tr.Close()).To(Succeed()) + Expect(string(got)).To(Equal("hello world")) + }) + + It("does not call onComplete when the stream is not fully read", func() { + called := false + src := io.NopCloser(bytes.NewReader([]byte("hello world"))) + tr := newTeeReader(src, 1024, func(data []byte) { called = true }) + buf := make([]byte, 3) + _, err := tr.Read(buf) // partial read, then close without EOF + Expect(err).ToNot(HaveOccurred()) + Expect(tr.Close()).To(Succeed()) + Expect(called).To(BeFalse()) + }) + + It("does not call onComplete when the data exceeds maxBytes", func() { + called := false + src := io.NopCloser(bytes.NewReader([]byte("hello world"))) + tr := newTeeReader(src, 4, func(data []byte) { called = true }) + _, err := io.ReadAll(tr) + Expect(err).ToNot(HaveOccurred()) + Expect(tr.Close()).To(Succeed()) + Expect(called).To(BeFalse()) + }) +}) From f6dd722119374809a982a9c6cb5252133c6e419f Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 17 Jul 2026 20:34:33 -0400 Subject: [PATCH 30/46] refactor(artwork): compute blurhash from served bytes, drop proxy signals The worker no longer infers whether the served bytes changed through a stack of proxy signals (snapshot timestamp vs stored blur_hash_updated_at, freshness guard, gone bit, idempotent-write skip, computeFromArtwork re-read). It now takes the exact bytes captured from a serve and is a pure function of them: placeholder clears, undecodable is left alone, otherwise encode and write with an in-memory last-hash dedup. Deletion is a checkGone job that re-reads once and clears only if the source is still gone, so a transient fetch failure can't clobber a valid hash. --- core/artwork/blurhash_updater.go | 255 ++++++++---------- .../artwork/blurhash_updater_internal_test.go | 177 ++++++------ 2 files changed, 184 insertions(+), 248 deletions(-) diff --git a/core/artwork/blurhash_updater.go b/core/artwork/blurhash_updater.go index 224e2e087..67c76dbd7 100644 --- a/core/artwork/blurhash_updater.go +++ b/core/artwork/blurhash_updater.go @@ -17,22 +17,21 @@ import ( "github.com/navidrome/navidrome/resources" ) -// enqueueRequest carries the fill-time snapshot (the reader's LastUpdated, which folds row -// timestamps and live file mtimes into one clock). gone marks a serve that failed with -// ErrUnavailable: the only change no passive signal witnesses, so it clears a stale hash. -type enqueueRequest struct { - snapshot time.Time - gone bool +// blurHashJob is a unit of work: either bytes to hash (data != nil) or a deletion check (checkGone). +type blurHashJob struct { + data []byte + version time.Time + checkGone bool } -// blurHashUpdater keeps stored blurhashes in sync with the artwork entering the image cache. -// Computation is triggered by cache fills (every change), not serves, so the worker only re-derives -// the hash and skips idempotent writes: Enqueue is a cheap dedup insert, a single worker decodes -// and persists. +// blurHashUpdater keeps stored blurhashes in sync with the bytes actually served. The serve path tees +// the served image and hands it here; there is no change-detection proxy — the hash is a pure function +// of the captured bytes. A single worker decodes, encodes, and writes (dedup'd in memory). type blurHashUpdater struct { a *artwork mutex sync.Mutex - buffer map[model.ArtworkID]enqueueRequest + buffer map[model.ArtworkID]blurHashJob + last map[model.ArtworkID]string // last hash written this process; avoids redundant writes wake chan struct{} done chan struct{} runDone chan struct{} @@ -44,29 +43,35 @@ type blurHashUpdater struct { func newBlurHashUpdater(a *artwork) *blurHashUpdater { return &blurHashUpdater{ a: a, - buffer: make(map[model.ArtworkID]enqueueRequest), + buffer: make(map[model.ArtworkID]blurHashJob), + last: make(map[model.ArtworkID]string), wake: make(chan struct{}, 1), done: make(chan struct{}), runDone: make(chan struct{}), } } -// Enqueue schedules a recompute for an original-size cache fill, using the reader's snapshot as the -// artwork version. Called on the miss that fills the cache — i.e. exactly when the served bytes change. -func (u *blurHashUpdater) Enqueue(artID model.ArtworkID, snapshot time.Time) { - u.enqueue(artID, enqueueRequest{snapshot: snapshot}) -} - -// EnqueueGone schedules a clear for a serve that failed with ErrUnavailable, so a stored hash stops -// describing artwork that no longer exists (deletion is invisible to every passive signal). -func (u *blurHashUpdater) EnqueueGone(artID model.ArtworkID) { - u.enqueue(artID, enqueueRequest{gone: true}) -} - -func (u *blurHashUpdater) enqueue(artID model.ArtworkID, req enqueueRequest) { +func eligibleKind(artID model.ArtworkID) bool { switch artID.Kind { case model.KindAlbumArtwork, model.KindArtistArtwork, model.KindPlaylistArtwork: - default: + return true + } + return false +} + +// EnqueueBytes schedules a blurhash update computed from the exact bytes served for artID. +func (u *blurHashUpdater) EnqueueBytes(artID model.ArtworkID, data []byte, version time.Time) { + u.enqueue(artID, blurHashJob{data: data, version: version}) +} + +// EnqueueClearIfGone schedules a deletion check: the worker re-reads the source once and clears the +// stored hash only if it still fails/serves a placeholder, so a transient failure won't clobber it. +func (u *blurHashUpdater) EnqueueClearIfGone(artID model.ArtworkID, version time.Time) { + u.enqueue(artID, blurHashJob{checkGone: true, version: version}) +} + +func (u *blurHashUpdater) enqueue(artID model.ArtworkID, job blurHashJob) { + if !eligibleKind(artID) { return } u.mutex.Lock() @@ -76,24 +81,18 @@ func (u *blurHashUpdater) enqueue(artID model.ArtworkID, req enqueueRequest) { } if !u.started { u.started = true - // Admin context: playlist artwork readers require a user. Lazy-starting keeps idle Artwork - // instances goroutine-free; stop() ends the worker (tests must call it, the server never does). + // Admin context: playlist artwork readers require a user. Lazy start keeps idle Artwork + // instances goroutine-free; stop() ends the worker (tests call it, the server never does). ctx, cancel := context.WithCancel(request.WithUser(context.Background(), model.User{IsAdmin: true})) u.runCancel = cancel go u.run(ctx) } - prev := u.buffer[artID] - if !req.snapshot.IsZero() { - // A successful serve proves the artwork exists, so it supersedes any pending gone request for - // the same artwork (a cover restored right after a missing-art serve must still recompute). - prev.gone = false - if req.snapshot.After(prev.snapshot) { - prev.snapshot = req.snapshot - } - } else if req.gone { - prev.gone = true + // A bytes job supersedes a pending gone-check (a successful serve proves the artwork exists); + // otherwise keep whichever is newer. + prev, ok := u.buffer[artID] + if !ok || job.data != nil || (prev.checkGone && job.version.After(prev.version)) { + u.buffer[artID] = job } - u.buffer[artID] = prev u.mutex.Unlock() select { case u.wake <- struct{}{}: @@ -134,30 +133,30 @@ func (u *blurHashUpdater) run(ctx context.Context) { return default: } - artID, req, ok := u.next() + artID, job, ok := u.next() if !ok { break } - u.process(ctx, artID, req) + u.processJob(ctx, artID, job) } } } -func (u *blurHashUpdater) next() (model.ArtworkID, enqueueRequest, bool) { +func (u *blurHashUpdater) next() (model.ArtworkID, blurHashJob, bool) { u.mutex.Lock() defer u.mutex.Unlock() - for artID, req := range u.buffer { + for artID, job := range u.buffer { delete(u.buffer, artID) - return artID, req, true + return artID, job, true } - return model.ArtworkID{}, enqueueRequest{}, false + return model.ArtworkID{}, blurHashJob{}, false } // processTimeout bounds one computation: readers can call external agents, and a hung call must not // stall the worker forever. const processTimeout = 30 * time.Second -func (u *blurHashUpdater) process(ctx context.Context, artID model.ArtworkID, req enqueueRequest) { +func (u *blurHashUpdater) processJob(ctx context.Context, artID model.ArtworkID, job blurHashJob) { // Artwork readers can touch storage, agents and plugins; a panic here must not kill the server. defer func() { if r := recover(); r != nil { @@ -166,115 +165,77 @@ func (u *blurHashUpdater) process(ctx context.Context, artID model.ArtworkID, re }() ctx, cancel := context.WithTimeout(ctx, processTimeout) defer cancel() - stored, storedAt, version, err := u.loadState(ctx, artID) - if err != nil { - log.Trace(ctx, "BlurHash: could not load entity", "artID", artID, err) - return - } - // A future-dated artwork file mtime (clock skew, a future-stamped file) would otherwise be - // persisted verbatim and, via the !Before checks here and in the DTO, pin the stored hash until - // wall time caught up. Cap the snapshot at now so a later real change always moves past it. - now := time.Now() - snapshot := req.snapshot - if snapshot.After(now) { - snapshot = now - } - if req.gone { - // Only the failed serve witnesses a deletion; clear a stored hash so the DTO falls back to - // the rotating fake. An empty hash means there is nothing to clear. - if stored != "" { - if err := u.persist(ctx, artID, "", version); err != nil { - log.Warn(ctx, "BlurHash: error clearing stale hash", "artID", artID, err) - } - } - return - } - // snapshot folds row timestamps and file mtimes into one clock; a stored hash at or after it is - // already current. This is the only freshness comparison the fill trigger needs. - if stored != "" && storedAt != nil && !storedAt.Before(snapshot) { - return - } - hash, err := u.computeFromArtwork(ctx, artID) - if err != nil { - // A transient failure (timeout, flaky cache/DB read) is not evidence the artwork changed; - // leave the stored hash intact and let a later fill retry, so clients don't churn on a fake. - log.Trace(ctx, "BlurHash: recompute failed, keeping stored hash", "artID", artID, err) - return - } - if hash == "" { - // An empty hash means the served bytes are a placeholder: the cover is gone. Clear a stored - // hash so the DTO stops describing artwork no longer served. - if stored != "" { - if err := u.persist(ctx, artID, "", snapshot); err != nil { - log.Warn(ctx, "BlurHash: error clearing stale hash", "artID", artID, err) - } - } - return - } - // Unchanged hash with an unmoved snapshot needs no write — keeps cache-disabled installs (which - // fill on every original serve) from hammering the DB. - if hash == stored && storedAt != nil && !snapshot.After(*storedAt) { - return - } - if err := u.persist(ctx, artID, hash, snapshot); 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 + if job.checkGone { + u.processGone(ctx, artID, job.version) + return } - 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 isPlaceholder(job.data) { + u.clear(ctx, artID, job.version) + return + } + img, _, err := image.Decode(bytes.NewReader(job.data)) if err != nil { - return "", err - } - // 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() - data, err := io.ReadAll(r) - if err != nil { - return "", err - } - if isPlaceholder(data) { - return "", nil - } - img, _, err := image.Decode(bytes.NewReader(data)) - if err != nil { - return "", err + // Undecodable served bytes are not proof of change; leave the stored hash intact. + log.Trace(ctx, "BlurHash: served bytes not decodable, keeping stored hash", "artID", artID, err) + return } b := img.Bounds() x, y := blurhash.Components(b.Dx(), b.Dy()) - return blurhash.Encode(img, x, y) + hash, err := blurhash.Encode(img, x, y) + if err != nil || hash == "" { + return + } + u.write(ctx, artID, hash, job.version) } -// 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. +// processGone re-reads the source once; if it still yields a placeholder or fails, the artwork is +// really gone and the stored hash is cleared. A transient failure recovers by now and is left alone. +func (u *blurHashUpdater) processGone(ctx context.Context, artID model.ArtworkID, version time.Time) { + artReader, err := u.a.getArtworkReader(ctx, artID, 0, false) + if err == nil { + r, gErr := u.a.cache.Get(ctx, artReader) + if gErr == nil { + data, rErr := io.ReadAll(r) + _ = r.Close() + if rErr == nil && !isPlaceholder(data) { + return // source came back (or never really failed): keep the hash + } + } + } + u.clear(ctx, artID, version) +} + +func (u *blurHashUpdater) write(ctx context.Context, artID model.ArtworkID, hash string, version time.Time) { + u.mutex.Lock() + if u.last[artID] == hash { + u.mutex.Unlock() + return + } + u.mutex.Unlock() + if err := u.persist(ctx, artID, hash, version); err != nil { + log.Warn(ctx, "BlurHash: error persisting", "artID", artID, err) + return + } + u.mutex.Lock() + u.last[artID] = hash + u.mutex.Unlock() +} + +func (u *blurHashUpdater) clear(ctx context.Context, artID model.ArtworkID, version time.Time) { + // No cold-map dedup: an empty u.last[artID] means "never written" as easily as "already cleared", + // so skipping would leave a previous process's DB hash describing gone artwork. Clears are rare. + if err := u.persist(ctx, artID, "", version); err != nil { + log.Warn(ctx, "BlurHash: error clearing hash", "artID", artID, err) + return + } + u.mutex.Lock() + u.last[artID] = "" + u.mutex.Unlock() +} + +// isPlaceholder byte-compares against the embedded placeholder assets: placeholder artwork must never +// be persisted as an entity's blurhash, and captured bytes carry no source path to check. func isPlaceholder(data []byte) bool { for _, p := range placeholderImages() { if bytes.Equal(data, p) { diff --git a/core/artwork/blurhash_updater_internal_test.go b/core/artwork/blurhash_updater_internal_test.go index 8a27a2c9e..9f95a064c 100644 --- a/core/artwork/blurhash_updater_internal_test.go +++ b/core/artwork/blurhash_updater_internal_test.go @@ -1,7 +1,10 @@ package artwork import ( - "errors" + "bytes" + "image" + "image/color" + "image/png" "time" "github.com/navidrome/navidrome/model" @@ -10,131 +13,103 @@ import ( . "github.com/onsi/gomega" ) -// failingFolderRepo makes the artwork reader chain fail with a clean (transient-style) error. -type failingFolderRepo struct{ model.FolderRepository } +// pngImage builds a deterministic 2x2 PNG image for a label (color derived from label bytes). +func pngImage(label string) *image.RGBA { + img := image.NewRGBA(image.Rect(0, 0, 2, 2)) + var seed byte + for i := range len(label) { + seed += label[i] + } + c := color.RGBA{R: seed, G: seed * 3, B: seed * 7, A: 255} + for y := range 2 { + for x := range 2 { + img.Set(x, y, c) + } + } + return img +} -func (failingFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) { - return nil, errors.New("boom") +func realPNGBytes(label string) []byte { + var buf bytes.Buffer + Expect(png.Encode(&buf, pngImage(label))).To(Succeed()) + return buf.Bytes() } var _ = Describe("blurHashUpdater", func() { var u *blurHashUpdater var ds *tests.MockDataStore + var version time.Time BeforeEach(func() { ds = &tests.MockDataStore{} - // started is pre-set so Enqueue never spawns run(): tests drive next()/process() directly. u = &blurHashUpdater{ a: &artwork{ds: ds}, - buffer: make(map[model.ArtworkID]enqueueRequest), + buffer: make(map[model.ArtworkID]blurHashJob), wake: make(chan struct{}, 1), + last: make(map[model.ArtworkID]string), started: true, } + version = time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) }) - Describe("Enqueue", func() { - It("accepts album, artist and playlist artwork and dedups, keeping the newest snapshot", func() { - id := model.Album{ID: "al-1"}.CoverArtID() - t1 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) - t2 := t1.Add(time.Hour) - u.Enqueue(id, t1) - u.Enqueue(id, t2) - u.Enqueue(model.Artist{ID: "ar-1"}.CoverArtID(), t1) - Expect(u.buffer).To(HaveLen(2)) - Expect(u.buffer[id].snapshot).To(Equal(t2)) - Expect(u.buffer[id].gone).To(BeFalse()) - }) + It("persists a hash computed from the given bytes", func() { + al := model.Album{ID: "al-1", UpdatedAt: version} + repo := tests.CreateMockAlbumRepo() + repo.SetData(model.Albums{al}) + ds.MockedAlbum = repo - It("keeps a gone flag when it follows a pending fill (cover then vanished)", func() { - id := model.Album{ID: "al-1"}.CoverArtID() - t1 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) - u.Enqueue(id, t1) - u.EnqueueGone(id) - Expect(u.buffer[id].snapshot).To(Equal(t1)) - Expect(u.buffer[id].gone).To(BeTrue()) - }) - - It("lets a successful fill supersede a pending gone (cover restored)", func() { - id := model.Album{ID: "al-1"}.CoverArtID() - t1 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) - u.EnqueueGone(id) - u.Enqueue(id, t1) - Expect(u.buffer[id].snapshot).To(Equal(t1)) - Expect(u.buffer[id].gone).To(BeFalse()) - }) - - It("ignores other artwork kinds", func() { - u.Enqueue(model.ArtworkID{Kind: model.KindMediaFileArtwork, ID: "mf-1"}, time.Time{}) - u.EnqueueGone(model.ArtworkID{Kind: model.KindRadioArtwork, ID: "ra-1"}) - Expect(u.buffer).To(BeEmpty()) - }) + u.processJob(GinkgoT().Context(), al.CoverArtID(), blurHashJob{data: realPNGBytes("x"), version: version}) + stored, err := ds.Album(GinkgoT().Context()).Get("al-1") + Expect(err).ToNot(HaveOccurred()) + Expect(stored.BlurHash).ToNot(BeEmpty()) }) - Describe("process", func() { - var version time.Time + It("clears the hash when the bytes are a placeholder", func() { + al := model.Album{ID: "al-1", UpdatedAt: version, BlurHash: "OLD"} + repo := tests.CreateMockAlbumRepo() + repo.SetData(model.Albums{al}) + ds.MockedAlbum = repo - BeforeEach(func() { - version = time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) - }) + u.processJob(GinkgoT().Context(), al.CoverArtID(), blurHashJob{data: placeholderImages()[0], version: version}) + stored, _ := ds.Album(GinkgoT().Context()).Get("al-1") + Expect(stored.BlurHash).To(BeEmpty()) // cleared: placeholder means gone + }) - It("skips entities whose stored hash is at or after the snapshot", func() { - al := model.Album{ID: "al-1", UpdatedAt: version, BlurHash: "LEHV6nWB2yk8", BlurHashUpdatedAt: &version} - repo := tests.CreateMockAlbumRepo() - repo.SetData(model.Albums{al}) - ds.MockedAlbum = repo + It("leaves the hash untouched on undecodable bytes", func() { + al := model.Album{ID: "al-1", UpdatedAt: version, BlurHash: "KEEP"} + repo := tests.CreateMockAlbumRepo() + repo.SetData(model.Albums{al}) + ds.MockedAlbum = repo - u.process(GinkgoT().Context(), al.CoverArtID(), enqueueRequest{snapshot: version}) - stored, err := ds.Album(GinkgoT().Context()).Get("al-1") - Expect(err).ToNot(HaveOccurred()) - Expect(stored.BlurHash).To(Equal("LEHV6nWB2yk8")) - }) + u.processJob(GinkgoT().Context(), al.CoverArtID(), blurHashJob{data: []byte("not an image"), version: version}) + stored, _ := ds.Album(GinkgoT().Context()).Get("al-1") + Expect(stored.BlurHash).To(Equal("KEEP")) + }) - It("keeps the stored hash when a recompute fails transiently", func() { - al := model.Album{ID: "al-1", UpdatedAt: version, BlurHash: "LEHV6nWB2yk8", BlurHashUpdatedAt: nil} - repo := tests.CreateMockAlbumRepo() - repo.SetData(model.Albums{al}) - ds.MockedAlbum = repo - ds.MockedFolder = failingFolderRepo{} + It("skips a redundant write when the hash is unchanged (in-memory dedup)", func() { + al := model.Album{ID: "al-1", UpdatedAt: version} + repo := tests.CreateMockAlbumRepo() + repo.SetData(model.Albums{al}) + ds.MockedAlbum = repo + data := realPNGBytes("dedup") - // A newer snapshot forces a recompute, but the reader chain errors (transient): the stored - // hash must survive, so clients don't churn on a fake until a later fill succeeds. - newer := version.Add(time.Hour) - u.process(GinkgoT().Context(), al.CoverArtID(), enqueueRequest{snapshot: newer}) - stored, err := ds.Album(GinkgoT().Context()).Get("al-1") - Expect(err).ToNot(HaveOccurred()) - Expect(stored.BlurHash).To(Equal("LEHV6nWB2yk8")) - }) + u.processJob(GinkgoT().Context(), al.CoverArtID(), blurHashJob{data: data, version: version}) + first, _ := ds.Album(GinkgoT().Context()).Get("al-1") + u.processJob(GinkgoT().Context(), al.CoverArtID(), blurHashJob{data: data, version: version.Add(time.Hour)}) + second, _ := ds.Album(GinkgoT().Context()).Get("al-1") + Expect(second.BlurHash).To(Equal(first.BlurHash)) + }) - It("clears a stored hash when the source is gone", func() { - al := model.Album{ID: "al-1", UpdatedAt: version, BlurHash: "LEHV6nWB2yk8", BlurHashUpdatedAt: &version} - repo := tests.CreateMockAlbumRepo() - repo.SetData(model.Albums{al}) - ds.MockedAlbum = repo + It("ignores non-eligible artwork kinds on enqueue", func() { + u.EnqueueBytes(model.ArtworkID{Kind: model.KindMediaFileArtwork, ID: "mf-1"}, realPNGBytes("x"), version) + Expect(u.buffer).To(BeEmpty()) + }) - u.process(GinkgoT().Context(), al.CoverArtID(), enqueueRequest{gone: true}) - stored, err := ds.Album(GinkgoT().Context()).Get("al-1") - Expect(err).ToNot(HaveOccurred()) - Expect(stored.BlurHash).To(BeEmpty()) - }) - - It("does nothing for a gone serve with no stored hash", func() { - al := model.Album{ID: "al-1", UpdatedAt: version} - repo := tests.CreateMockAlbumRepo() - repo.SetData(model.Albums{al}) - ds.MockedAlbum = repo - - Expect(func() { - u.process(GinkgoT().Context(), al.CoverArtID(), enqueueRequest{gone: true}) - }).ToNot(Panic()) - stored, _ := ds.Album(GinkgoT().Context()).Get("al-1") - Expect(stored.BlurHash).To(BeEmpty()) - }) - - It("does nothing when the entity is gone", func() { - ds.MockedAlbum = tests.CreateMockAlbumRepo() - Expect(func() { - u.process(GinkgoT().Context(), model.Album{ID: "missing"}.CoverArtID(), enqueueRequest{}) - }).ToNot(Panic()) - }) + It("supersedes a pending gone-check with a bytes job", func() { + id := model.Album{ID: "al-1"}.CoverArtID() + u.EnqueueClearIfGone(id, version) + u.EnqueueBytes(id, realPNGBytes("x"), version.Add(time.Hour)) + Expect(u.buffer[id].checkGone).To(BeFalse()) + Expect(u.buffer[id].data).ToNot(BeNil()) }) }) From e8fac4c3354cef5f868b9f3676b4dc04075ad51f Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 17 Jul 2026 20:34:47 -0400 Subject: [PATCH 31/46] feat(artwork): trigger blurhash from the served-bytes tee Get wraps an eligible original-size serve (album/artist/playlist) in a teeCachedStream so the bytes streamed to the client are also captured; on a fully-consumed Close the runner hashes exactly what was served. GetOrPlaceholder routes a vanished album/artist cover through EnqueueClearIfGone, since no bytes flow through the tee on ErrUnavailable. capAtNow keeps a future mtime out of the stored version. The mtime-preserved cover-swap characterization test (previously pending) now passes, and the disappearing-cover e2e serves through GetOrPlaceholder to match the real Jellyfin/Subsonic path. --- core/artwork/artwork.go | 38 +++++++++++++++++++++---------- core/artwork/blurhash_tee.go | 13 +++++++++++ core/artwork/e2e/blurhash_test.go | 14 ++++++------ core/artwork/e2e/helpers_test.go | 12 ++++++++++ 4 files changed, 58 insertions(+), 19 deletions(-) diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go index f3dcb7593..077ca31d4 100644 --- a/core/artwork/artwork.go +++ b/core/artwork/artwork.go @@ -19,6 +19,19 @@ import ( var ErrUnavailable = errors.New("artwork unavailable") +// maxTeeBytes bounds the per-serve capture buffer; artwork is a few MB, and anything larger is not +// hashed (skipped), so a pathological source can't accumulate unbounded memory across serves. +const maxTeeBytes = 20 * 1024 * 1024 + +// capAtNow keeps a future artwork mtime (clock skew, a future-stamped file) from being stored as the +// blurhash version, which would let the DTO's !Before check pin the hash until wall time caught up. +func capAtNow(t time.Time) time.Time { + if now := time.Now(); t.After(now) { + return now + } + return t +} + type Artwork interface { Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (io.ReadCloser, time.Time, error) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) @@ -59,6 +72,11 @@ func (a *artwork) GetOrPlaceholder(ctx context.Context, id string, size int, squ reader, lastUpdate, err = a.Get(ctx, artID, size, square) } if errors.Is(err, ErrUnavailable) { + if a.blurHashes != nil && eligibleKind(artID) { + // No bytes flowed through the tee; a real deletion must still clear the stored hash. The + // worker re-checks so a transient fetch failure doesn't clobber a valid hash. + a.blurHashes.EnqueueClearIfGone(artID, capAtNow(consts.ServerStart)) + } if artID.Kind == model.KindArtistArtwork { reader, _ = resources.FS().Open(consts.PlaceholderArtistArt) } else { @@ -80,21 +98,17 @@ func (a *artwork) Get(ctx context.Context, artID model.ArtworkID, size int, squa if !errors.Is(err, context.Canceled) && !errors.Is(err, ErrUnavailable) { log.Error(ctx, "Error accessing image cache", "id", artID, "size", size, err) } - // A vanished source must still reach the worker, or a stored hash would keep describing - // artwork that no longer exists. - if a.blurHashes != nil && errors.Is(err, ErrUnavailable) { - a.blurHashes.EnqueueGone(artID) - } return nil, time.Time{}, err } - if a.blurHashes != nil && size == 0 && !square { - // Every original-size serve carries the reader's true version; the worker's freshness guard - // turns already-hashed rows into a cheap read and only recomputes when the hash is stale or - // missing. Enqueuing on cache hits too (not just fills) is what backfills warm caches adopted - // from a pre-blurhash version, whose rows migrated in with an empty hash. - a.blurHashes.Enqueue(artID, artReader.LastUpdated()) + reader = r + if a.blurHashes != nil && size == 0 && !square && eligibleKind(artID) { + // Tee the served bytes: the blurhash is computed from exactly what the client downloads, so it + // changes precisely when the served cover changes. Placeholder bytes (playlist fallback) clear. + version := capAtNow(artReader.LastUpdated()) + reader = &teeCachedStream{CachedStream: r, tee: newTeeReader(io.NopCloser(r), maxTeeBytes, + func(data []byte) { a.blurHashes.EnqueueBytes(artID, data, version) })} } - return r, artReader.LastUpdated(), nil + return reader, artReader.LastUpdated(), nil } type coverArtGetter interface { diff --git a/core/artwork/blurhash_tee.go b/core/artwork/blurhash_tee.go index f603f1f65..60c0d3661 100644 --- a/core/artwork/blurhash_tee.go +++ b/core/artwork/blurhash_tee.go @@ -3,6 +3,8 @@ package artwork import ( "bytes" "io" + + "github.com/navidrome/navidrome/utils/cache" ) // teeReader mirrors bytes read from src into buf, and on Close invokes onComplete with the captured @@ -46,3 +48,14 @@ func (t *teeReader) Close() error { } return err } + +// teeCachedStream wraps a *cache.CachedStream so reads are teed for blurhash capture while callers +// still see a ReadCloser. Seek is intentionally dropped: blurhash-eligible serves are full reads +// (every artwork handler does io.Copy), so no caller Seeks a teed stream. +type teeCachedStream struct { + *cache.CachedStream + tee *teeReader +} + +func (t *teeCachedStream) Read(p []byte) (int, error) { return t.tee.Read(p) } +func (t *teeCachedStream) Close() error { return t.tee.Close() } diff --git a/core/artwork/e2e/blurhash_test.go b/core/artwork/e2e/blurhash_test.go index 148a3ff56..9c834a15c 100644 --- a/core/artwork/e2e/blurhash_test.go +++ b/core/artwork/e2e/blurhash_test.go @@ -74,8 +74,8 @@ var _ = Describe("BlurHash", func() { firstHash = updated.BlurHash }, "10s", "100ms").Should(Succeed()) - // Swap the cover bytes and rescan: the folder's image version moves, so the reader key moves, - // the cache misses and the fill re-triggers the compute — no serve-time force hint needed. + // Swap the cover bytes and rescan, then serve: the tee hashes the newly-served bytes, so the + // stored hash moves to describe the new cover. setLayout(fstest.MapFS{ "Artist/Album/01 - Song.mp3": trackFile(1, "Song"), "Artist/Album/cover.png": realPNG("swapped-cover"), @@ -105,13 +105,13 @@ var _ = Describe("BlurHash", func() { g.Expect(updated.BlurHash).ToNot(BeEmpty()) }, "10s", "100ms").Should(Succeed()) - // No rescan: the folder row still lists the cover, but the file is gone — the serve's - // ErrUnavailable alone must trigger the clear. + // No rescan: the folder row still lists the cover, but the file is gone. The serve falls back + // to the placeholder (GetOrPlaceholder, the real Jellyfin/Subsonic path), and the worker's + // gone-recheck confirms the source is really gone and clears the stored hash. setLayout(fstest.MapFS{ "Artist/Album/01 - Song.mp3": trackFile(1, "Song"), }) - _, err := readArtworkOrErr(al.CoverArtID()) - Expect(err).To(HaveOccurred()) + Expect(readOrPlaceholder(al.CoverArtID())).To(Equal(placeholderBytes())) Eventually(func(g Gomega) { updated, err := ds.Album(ctx).Get(al.ID) @@ -120,7 +120,7 @@ var _ = Describe("BlurHash", func() { }, "10s", "100ms").Should(Succeed()) }) - PIt("recomputes when cover bytes change under a preserved mtime (cache disabled)", func() { + It("recomputes when cover bytes change under a preserved mtime (cache disabled)", func() { cover := realPNG("orig-bytes") fixed := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) cover.ModTime = fixed diff --git a/core/artwork/e2e/helpers_test.go b/core/artwork/e2e/helpers_test.go index e3abca097..807c368e1 100644 --- a/core/artwork/e2e/helpers_test.go +++ b/core/artwork/e2e/helpers_test.go @@ -158,6 +158,18 @@ func readArtworkOrErr(artID model.ArtworkID) ([]byte, error) { return io.ReadAll(r) } +// readOrPlaceholder serves through GetOrPlaceholder — the path real Jellyfin/Subsonic handlers use — +// so a vanished album/artist cover falls back to the placeholder, whose bytes drive the blurhash clear. +func readOrPlaceholder(artID model.ArtworkID) []byte { + GinkgoHelper() + r, _, err := aw.GetOrPlaceholder(ctx, artID.String(), 0, false) + Expect(err).ToNot(HaveOccurred()) + defer r.Close() + b, err := io.ReadAll(r) + Expect(err).ToNot(HaveOccurred()) + return b +} + // noopProvider implements external.Provider with not-found returns so the // "external" priority entry never produces a result. type noopProvider struct{} From b63c9b095b9c48654796ee669583a228bdd27b9c Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 17 Jul 2026 20:36:38 -0400 Subject: [PATCH 32/46] test(artwork): cover playlist placeholder-clear via the tee A playlist that loses its sidecar cover serves the bundled placeholder through Get; those bytes flow through the tee, the runner recognizes the placeholder, and clears the stored hash. This exercises the free deletion path (no GetOrPlaceholder needed, since the playlist reader chain ends in fromAlbumPlaceholder). --- core/artwork/e2e/blurhash_test.go | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/core/artwork/e2e/blurhash_test.go b/core/artwork/e2e/blurhash_test.go index 9c834a15c..f34c09c15 100644 --- a/core/artwork/e2e/blurhash_test.go +++ b/core/artwork/e2e/blurhash_test.go @@ -1,9 +1,12 @@ package artworke2e_test import ( + "os" + "path/filepath" "testing/fstest" "time" + "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -155,6 +158,34 @@ var _ = Describe("BlurHash", func() { }, "10s", "100ms").Should(Succeed()) }) + It("clears a stored playlist hash when it falls back to the placeholder", func() { + // A playlist with a sidecar cover gets a real hash; removing the sidecar makes the reader chain + // fall through to fromAlbumPlaceholder(), whose bytes flow through the tee on Get and clear it. + dir := GinkgoT().TempDir() + m3uPath := filepath.Join(dir, "MyList.m3u") + Expect(os.WriteFile(m3uPath, []byte("#EXTM3U\n"), 0600)).To(Succeed()) + sidecar := filepath.Join(dir, "MyList.png") + Expect(os.WriteFile(sidecar, realPNG("pl-cover").Data, 0600)).To(Succeed()) + + pl := putPlaylist(model.Playlist{ID: "pl-blur", Name: "MyList", Path: m3uPath}) + readArtwork(pl.CoverArtID()) + Eventually(func(g Gomega) { + updated, err := ds.Playlist(ctx).Get(pl.ID) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(updated.BlurHash).ToNot(BeEmpty()) + }, "10s", "100ms").Should(Succeed()) + + // Remove the sidecar: the serve now falls through to the placeholder, captured by the tee. + Expect(os.Remove(sidecar)).To(Succeed()) + Expect(readArtwork(pl.CoverArtID())).To(Equal(placeholderBytes())) + + Eventually(func(g Gomega) { + updated, err := ds.Playlist(ctx).Get(pl.ID) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(updated.BlurHash).To(BeEmpty()) + }, "10s", "100ms").Should(Succeed()) + }) + It("does not persist a blurhash when the served image cannot be decoded", func() { setLayout(fstest.MapFS{ "Artist/Album/01 - Song.mp3": trackFile(1, "Song"), From 7307fd716b68d19558d924a69c32f2357f8cde88 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 17 Jul 2026 21:13:03 -0400 Subject: [PATCH 33/46] fix(artwork): close the underlying stream from the tee; rename to tee_reader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tee was built around io.NopCloser(r) and teeCachedStream.Close only closed the tee, so the underlying CachedStream (an open cache-file fd, or the raw source stream when the image cache is disabled) was never closed — one fd leaked per teed serve, enough to exhaust the process limit during a client's initial cover sync. The tee now wraps the stream directly and its Close propagates; teeCachedStream is gone (all artwork handlers io.Copy, none Seek). The file is renamed to tee_reader.go since the wrapper is generic, not blurhash-specific. --- core/artwork/artwork.go | 5 +++-- core/artwork/{blurhash_tee.go => tee_reader.go} | 15 +-------------- ...ernal_test.go => tee_reader_internal_test.go} | 16 ++++++++++++++++ 3 files changed, 20 insertions(+), 16 deletions(-) rename core/artwork/{blurhash_tee.go => tee_reader.go} (61%) rename core/artwork/{blurhash_tee_internal_test.go => tee_reader_internal_test.go} (73%) diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go index 077ca31d4..9b89230b9 100644 --- a/core/artwork/artwork.go +++ b/core/artwork/artwork.go @@ -104,9 +104,10 @@ func (a *artwork) Get(ctx context.Context, artID model.ArtworkID, size int, squa if a.blurHashes != nil && size == 0 && !square && eligibleKind(artID) { // Tee the served bytes: the blurhash is computed from exactly what the client downloads, so it // changes precisely when the served cover changes. Placeholder bytes (playlist fallback) clear. + // The tee wraps r directly, so Close reaches the underlying stream (no fd leak). version := capAtNow(artReader.LastUpdated()) - reader = &teeCachedStream{CachedStream: r, tee: newTeeReader(io.NopCloser(r), maxTeeBytes, - func(data []byte) { a.blurHashes.EnqueueBytes(artID, data, version) })} + reader = newTeeReader(r, maxTeeBytes, + func(data []byte) { a.blurHashes.EnqueueBytes(artID, data, version) }) } return reader, artReader.LastUpdated(), nil } diff --git a/core/artwork/blurhash_tee.go b/core/artwork/tee_reader.go similarity index 61% rename from core/artwork/blurhash_tee.go rename to core/artwork/tee_reader.go index 60c0d3661..e55592ab0 100644 --- a/core/artwork/blurhash_tee.go +++ b/core/artwork/tee_reader.go @@ -3,13 +3,11 @@ package artwork import ( "bytes" "io" - - "github.com/navidrome/navidrome/utils/cache" ) // teeReader mirrors bytes read from src into buf, and on Close invokes onComplete with the captured // bytes only if the stream was fully consumed (EOF) and stayed within maxBytes. Partial reads and -// oversized streams are skipped, so a hash is only ever computed from a complete, bounded image. +// oversized streams are skipped, so the callback only ever receives a complete, bounded payload. type teeReader struct { src io.ReadCloser buf bytes.Buffer @@ -48,14 +46,3 @@ func (t *teeReader) Close() error { } return err } - -// teeCachedStream wraps a *cache.CachedStream so reads are teed for blurhash capture while callers -// still see a ReadCloser. Seek is intentionally dropped: blurhash-eligible serves are full reads -// (every artwork handler does io.Copy), so no caller Seeks a teed stream. -type teeCachedStream struct { - *cache.CachedStream - tee *teeReader -} - -func (t *teeCachedStream) Read(p []byte) (int, error) { return t.tee.Read(p) } -func (t *teeCachedStream) Close() error { return t.tee.Close() } diff --git a/core/artwork/blurhash_tee_internal_test.go b/core/artwork/tee_reader_internal_test.go similarity index 73% rename from core/artwork/blurhash_tee_internal_test.go rename to core/artwork/tee_reader_internal_test.go index 595bbb099..64cfecf19 100644 --- a/core/artwork/blurhash_tee_internal_test.go +++ b/core/artwork/tee_reader_internal_test.go @@ -8,7 +8,23 @@ import ( . "github.com/onsi/gomega" ) +type closeSpy struct { + io.Reader + closed bool +} + +func (c *closeSpy) Close() error { c.closed = true; return nil } + var _ = Describe("teeReader", func() { + It("closes the underlying source exactly once", func() { + src := &closeSpy{Reader: bytes.NewReader([]byte("hello"))} + tr := newTeeReader(src, 1024, func([]byte) {}) + _, err := io.ReadAll(tr) + Expect(err).ToNot(HaveOccurred()) + Expect(tr.Close()).To(Succeed()) + Expect(src.closed).To(BeTrue(), "the source stream must be closed, or its fd leaks") + }) + It("calls onComplete with the full bytes after a complete read+close", func() { var got []byte src := io.NopCloser(bytes.NewReader([]byte("hello world"))) From 83e9bb81804b2309913168c054848fe28f27c42e Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 17 Jul 2026 21:20:46 -0400 Subject: [PATCH 34/46] refactor(artwork): compute the blurhash inline, drop the background worker Decode+encode is a few ms (the encoder downscales before its pixel loops), and the tee fires on Close after the response is fully written, so the hash can be computed in the serving goroutine: the queue, wake/stop lifecycle, lazy-start admin context, and the e2e worker-teardown ordering all become unnecessary and are removed. This also closes two correctness holes the queue had: a deletion signal could be dropped behind a pending serve job, and buffered image bytes had no global cap. The in-memory dedup now remembers a checksum of the served bytes plus the persisted version: identical serves skip the decode and the write, but the same bytes under a newer entity version re-persist, so blur_hash_updated_at keeps pace with row updates and the Jellyfin DTO's staleness gate keeps emitting the stored hash after scans. Placeholder-triggered clears check the seen map, then the stored row, so coverless entities cost one row read once per process instead of a probe and write per serve. UpdateBlurHash is a plain UPDATE with no user filtering, so the request context is used directly (a client abort is survived via context.WithoutCancel). --- core/artwork/artwork.go | 21 +- core/artwork/artwork_internal_test.go | 2 - core/artwork/artwork_test.go | 2 - core/artwork/benchmark_e2e_test.go | 1 - core/artwork/blurhash_updater.go | 277 +++++++----------- .../artwork/blurhash_updater_internal_test.go | 107 +++---- core/artwork/e2e/suite_test.go | 15 - 7 files changed, 164 insertions(+), 261 deletions(-) diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go index 9b89230b9..a22cc471d 100644 --- a/core/artwork/artwork.go +++ b/core/artwork/artwork.go @@ -39,19 +39,10 @@ type Artwork interface { func NewArtwork(ds model.DataStore, cache cache.FileCache, ffmpeg ffmpeg.FFmpeg, provider external.Provider) Artwork { a := &artwork{ds: ds, cache: cache, ffmpeg: ffmpeg, provider: provider} - a.blurHashes = newBlurHashUpdater(a) + a.blurHashes = newBlurHashUpdater(ds) return a } -// Close stops the background blurhash worker. The server never calls it; tests must, so a leaked -// worker can't touch mocks/filesystems being torn down by the next spec. -func (a *artwork) Close() error { - if a.blurHashes != nil { - a.blurHashes.stop() - } - return nil -} - type artwork struct { ds model.DataStore cache cache.FileCache @@ -72,10 +63,10 @@ func (a *artwork) GetOrPlaceholder(ctx context.Context, id string, size int, squ reader, lastUpdate, err = a.Get(ctx, artID, size, square) } if errors.Is(err, ErrUnavailable) { - if a.blurHashes != nil && eligibleKind(artID) { - // No bytes flowed through the tee; a real deletion must still clear the stored hash. The - // worker re-checks so a transient fetch failure doesn't clobber a valid hash. - a.blurHashes.EnqueueClearIfGone(artID, capAtNow(consts.ServerStart)) + if a.blurHashes != nil { + // The client is receiving the placeholder, so a stored hash describing the old cover must + // clear — hash-what-you-serve applies to the fallback too. + a.blurHashes.clearIfStored(ctx, artID, time.Now()) } if artID.Kind == model.KindArtistArtwork { reader, _ = resources.FS().Open(consts.PlaceholderArtistArt) @@ -107,7 +98,7 @@ func (a *artwork) Get(ctx context.Context, artID model.ArtworkID, size int, squa // The tee wraps r directly, so Close reaches the underlying stream (no fd leak). version := capAtNow(artReader.LastUpdated()) reader = newTeeReader(r, maxTeeBytes, - func(data []byte) { a.blurHashes.EnqueueBytes(artID, data, version) }) + func(data []byte) { a.blurHashes.update(ctx, artID, data, version) }) } return reader, artReader.LastUpdated(), nil } diff --git a/core/artwork/artwork_internal_test.go b/core/artwork/artwork_internal_test.go index cdb3d3204..c95371959 100644 --- a/core/artwork/artwork_internal_test.go +++ b/core/artwork/artwork_internal_test.go @@ -67,8 +67,6 @@ var _ = Describe("Artwork", func() { cache := GetImageCache() ffmpeg = tests.NewMockFFmpeg("content from ffmpeg") aw = NewArtwork(ds, cache, ffmpeg, nil).(*artwork) - // Stop the blurhash worker up front: it would mutate the non-thread-safe mocks mid-spec. - Expect(aw.Close()).To(Succeed()) }) Describe("albumArtworkReader", func() { diff --git a/core/artwork/artwork_test.go b/core/artwork/artwork_test.go index b3e7b9421..adddd0dc3 100644 --- a/core/artwork/artwork_test.go +++ b/core/artwork/artwork_test.go @@ -26,8 +26,6 @@ var _ = Describe("Artwork", func() { cache := artwork.GetImageCache() ffmpeg = tests.NewMockFFmpeg("content from ffmpeg") aw = artwork.NewArtwork(ds, cache, ffmpeg, nil) - // Stop the blurhash worker up front: it would mutate the non-thread-safe mocks mid-spec. - Expect(aw.(io.Closer).Close()).To(Succeed()) }) Context("GetOrPlaceholder", func() { diff --git a/core/artwork/benchmark_e2e_test.go b/core/artwork/benchmark_e2e_test.go index 69a04a7c3..bf3d435a8 100644 --- a/core/artwork/benchmark_e2e_test.go +++ b/core/artwork/benchmark_e2e_test.go @@ -95,7 +95,6 @@ func setupE2EBenchmark(b *testing.B, cacheSize string) (Artwork, model.ArtworkID aw := NewArtwork(ds, imgCache, ffmpeg, nil) cleanupAll := func() { - _ = aw.(*artwork).Close() os.RemoveAll(tmpDir) } return aw, artID, cleanupAll diff --git a/core/artwork/blurhash_updater.go b/core/artwork/blurhash_updater.go index 67c76dbd7..0e05206fb 100644 --- a/core/artwork/blurhash_updater.go +++ b/core/artwork/blurhash_updater.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "hash/fnv" "image" "io" "sync" @@ -13,42 +14,28 @@ import ( "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" ) -// blurHashJob is a unit of work: either bytes to hash (data != nil) or a deletion check (checkGone). -type blurHashJob struct { - data []byte - version time.Time - checkGone bool +// blurHashState remembers what was last persisted for an artwork, keyed by a checksum of the served +// bytes, so repeated serves of the same image skip the decode and the write entirely. +type blurHashState struct { + sum uint64 + version time.Time + hash string } -// blurHashUpdater keeps stored blurhashes in sync with the bytes actually served. The serve path tees -// the served image and hands it here; there is no change-detection proxy — the hash is a pure function -// of the captured bytes. A single worker decodes, encodes, and writes (dedup'd in memory). +// blurHashUpdater keeps stored blurhashes in sync with the bytes actually served. It runs inline in +// the serving goroutine after the response is fully written (decode+encode is a few ms): the hash is +// a pure function of the captured bytes — no change-detection proxy, no background worker. type blurHashUpdater struct { - a *artwork - mutex sync.Mutex - buffer map[model.ArtworkID]blurHashJob - last map[model.ArtworkID]string // last hash written this process; avoids redundant writes - wake chan struct{} - done chan struct{} - runDone chan struct{} - runCancel context.CancelFunc - started bool - stopped bool + ds model.DataStore + mutex sync.Mutex + seen map[model.ArtworkID]blurHashState } -func newBlurHashUpdater(a *artwork) *blurHashUpdater { - return &blurHashUpdater{ - a: a, - buffer: make(map[model.ArtworkID]blurHashJob), - last: make(map[model.ArtworkID]string), - wake: make(chan struct{}, 1), - done: make(chan struct{}), - runDone: make(chan struct{}), - } +func newBlurHashUpdater(ds model.DataStore) *blurHashUpdater { + return &blurHashUpdater{ds: ds, seen: make(map[model.ArtworkID]blurHashState)} } func eligibleKind(artID model.ArtworkID) bool { @@ -59,122 +46,35 @@ func eligibleKind(artID model.ArtworkID) bool { return false } -// EnqueueBytes schedules a blurhash update computed from the exact bytes served for artID. -func (u *blurHashUpdater) EnqueueBytes(artID model.ArtworkID, data []byte, version time.Time) { - u.enqueue(artID, blurHashJob{data: data, version: version}) -} - -// EnqueueClearIfGone schedules a deletion check: the worker re-reads the source once and clears the -// stored hash only if it still fails/serves a placeholder, so a transient failure won't clobber it. -func (u *blurHashUpdater) EnqueueClearIfGone(artID model.ArtworkID, version time.Time) { - u.enqueue(artID, blurHashJob{checkGone: true, version: version}) -} - -func (u *blurHashUpdater) enqueue(artID model.ArtworkID, job blurHashJob) { - if !eligibleKind(artID) { - return - } - u.mutex.Lock() - if u.stopped { - u.mutex.Unlock() - return - } - if !u.started { - u.started = true - // Admin context: playlist artwork readers require a user. Lazy start keeps idle Artwork - // instances goroutine-free; stop() ends the worker (tests call it, the server never does). - ctx, cancel := context.WithCancel(request.WithUser(context.Background(), model.User{IsAdmin: true})) - u.runCancel = cancel - go u.run(ctx) - } - // A bytes job supersedes a pending gone-check (a successful serve proves the artwork exists); - // otherwise keep whichever is newer. - prev, ok := u.buffer[artID] - if !ok || job.data != nil || (prev.checkGone && job.version.After(prev.version)) { - u.buffer[artID] = job - } - u.mutex.Unlock() - select { - case u.wake <- struct{}{}: - default: - } -} - -// stop ends the worker and waits for any in-flight computation, so callers can safely tear down the -// resources (DataStore, filesystems) the worker touches. -func (u *blurHashUpdater) stop() { - u.mutex.Lock() - if u.stopped { - u.mutex.Unlock() - return - } - u.stopped = true - started := u.started - cancel := u.runCancel - u.mutex.Unlock() - close(u.done) - if started { - cancel() - <-u.runDone - } -} - -func (u *blurHashUpdater) run(ctx context.Context) { - defer close(u.runDone) - for { - select { - case <-u.done: - return - case <-u.wake: - } - for { - select { - case <-u.done: - return - default: - } - artID, job, ok := u.next() - if !ok { - break - } - u.processJob(ctx, artID, job) - } - } -} - -func (u *blurHashUpdater) next() (model.ArtworkID, blurHashJob, bool) { - u.mutex.Lock() - defer u.mutex.Unlock() - for artID, job := range u.buffer { - delete(u.buffer, artID) - return artID, job, true - } - return model.ArtworkID{}, blurHashJob{}, false -} - -// processTimeout bounds one computation: readers can call external agents, and a hung call must not -// stall the worker forever. -const processTimeout = 30 * time.Second - -func (u *blurHashUpdater) processJob(ctx context.Context, artID model.ArtworkID, job blurHashJob) { - // Artwork readers can touch storage, agents and plugins; a panic here must not kill the server. +// update hashes the exact bytes served for artID and persists the result. Placeholder bytes mean the +// entity has no artwork anymore, so they clear a stored hash instead. +func (u *blurHashUpdater) update(ctx context.Context, artID model.ArtworkID, data []byte, version time.Time) { + // Decoding arbitrary image bytes can panic; the serve already succeeded, so just log it. defer func() { if r := recover(); r != nil { log.Error(ctx, "BlurHash: recovered from panic", "artID", artID, "panic", r) } }() - ctx, cancel := context.WithTimeout(ctx, processTimeout) - defer cancel() - - if job.checkGone { - u.processGone(ctx, artID, job.version) + // The response is already written when the tee fires; a client abort must not lose the write. + ctx = context.WithoutCancel(ctx) + if isPlaceholder(data) { + u.clearIfStored(ctx, artID, version) return } - if isPlaceholder(job.data) { - u.clear(ctx, artID, job.version) + sum := checksum(data) + u.mutex.Lock() + prev, ok := u.seen[artID] + u.mutex.Unlock() + if ok && prev.hash != "" && prev.sum == sum { + if !version.After(prev.version) { + return + } + // Same bytes under a newer artwork version: re-persist so blur_hash_updated_at keeps pace with + // the entity version, or the DTO staleness gate would emit the fake after any routine scan. + u.persistAndRemember(ctx, artID, prev.hash, sum, version) return } - img, _, err := image.Decode(bytes.NewReader(job.data)) + img, _, err := image.Decode(bytes.NewReader(data)) if err != nil { // Undecodable served bytes are not proof of change; leave the stored hash intact. log.Trace(ctx, "BlurHash: served bytes not decodable, keeping stored hash", "artID", artID, err) @@ -186,54 +86,83 @@ func (u *blurHashUpdater) processJob(ctx context.Context, artID model.ArtworkID, if err != nil || hash == "" { return } - u.write(ctx, artID, hash, job.version) + u.persistAndRemember(ctx, artID, hash, sum, version) } -// processGone re-reads the source once; if it still yields a placeholder or fails, the artwork is -// really gone and the stored hash is cleared. A transient failure recovers by now and is left alone. -func (u *blurHashUpdater) processGone(ctx context.Context, artID model.ArtworkID, version time.Time) { - artReader, err := u.a.getArtworkReader(ctx, artID, 0, false) - if err == nil { - r, gErr := u.a.cache.Get(ctx, artReader) - if gErr == nil { - data, rErr := io.ReadAll(r) - _ = r.Close() - if rErr == nil && !isPlaceholder(data) { - return // source came back (or never really failed): keep the hash - } +// clearIfStored clears the persisted hash after a placeholder was served (a cold map costs one row +// read to skip never-hashed entities); a failed read clears nothing — unknown state is not deletion. +func (u *blurHashUpdater) clearIfStored(ctx context.Context, artID model.ArtworkID, version time.Time) { + if !eligibleKind(artID) { + return + } + ctx = context.WithoutCancel(ctx) + u.mutex.Lock() + prev, ok := u.seen[artID] + u.mutex.Unlock() + if ok && prev.hash == "" { + return + } + if !ok { + stored, err := u.loadStoredHash(ctx, artID) + if err != nil { + return + } + if stored == "" { + u.remember(artID, blurHashState{version: version}) + return } } - u.clear(ctx, artID, version) -} - -func (u *blurHashUpdater) write(ctx context.Context, artID model.ArtworkID, hash string, version time.Time) { - u.mutex.Lock() - if u.last[artID] == hash { - u.mutex.Unlock() - return - } - u.mutex.Unlock() - if err := u.persist(ctx, artID, hash, version); err != nil { - log.Warn(ctx, "BlurHash: error persisting", "artID", artID, err) - return - } - u.mutex.Lock() - u.last[artID] = hash - u.mutex.Unlock() -} - -func (u *blurHashUpdater) clear(ctx context.Context, artID model.ArtworkID, version time.Time) { - // No cold-map dedup: an empty u.last[artID] means "never written" as easily as "already cleared", - // so skipping would leave a previous process's DB hash describing gone artwork. Clears are rare. if err := u.persist(ctx, artID, "", version); err != nil { log.Warn(ctx, "BlurHash: error clearing hash", "artID", artID, err) return } + u.remember(artID, blurHashState{version: version}) +} + +func (u *blurHashUpdater) persistAndRemember(ctx context.Context, artID model.ArtworkID, hash string, sum uint64, version time.Time) { + if err := u.persist(ctx, artID, hash, version); err != nil { + log.Warn(ctx, "BlurHash: error persisting", "artID", artID, err) + return + } + u.remember(artID, blurHashState{sum: sum, version: version, hash: hash}) +} + +func (u *blurHashUpdater) remember(artID model.ArtworkID, s blurHashState) { u.mutex.Lock() - u.last[artID] = "" + u.seen[artID] = s u.mutex.Unlock() } +func checksum(data []byte) uint64 { + h := fnv.New64a() + _, _ = h.Write(data) + return h.Sum64() +} + +func (u *blurHashUpdater) loadStoredHash(ctx context.Context, artID model.ArtworkID) (string, error) { + switch artID.Kind { + case model.KindAlbumArtwork: + al, err := u.ds.Album(ctx).Get(artID.ID) + if err != nil { + return "", err + } + return al.BlurHash, nil + case model.KindArtistArtwork: + ar, err := u.ds.Artist(ctx).Get(artID.ID) + if err != nil { + return "", err + } + return ar.BlurHash, nil + case model.KindPlaylistArtwork: + pl, err := u.ds.Playlist(ctx).Get(artID.ID) + if err != nil { + return "", err + } + return pl.BlurHash, nil + } + return "", model.ErrNotFound +} + // isPlaceholder byte-compares against the embedded placeholder assets: placeholder artwork must never // be persisted as an entity's blurhash, and captured bytes carry no source path to check. func isPlaceholder(data []byte) bool { @@ -261,11 +190,11 @@ var placeholderImages = sync.OnceValue(func() [][]byte { 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) + return u.ds.Album(ctx).UpdateBlurHash(artID.ID, hash, version) case model.KindArtistArtwork: - return u.a.ds.Artist(ctx).UpdateBlurHash(artID.ID, hash, version) + return u.ds.Artist(ctx).UpdateBlurHash(artID.ID, hash, version) case model.KindPlaylistArtwork: - return u.a.ds.Playlist(ctx).UpdateBlurHash(artID.ID, hash, version) + return u.ds.Playlist(ctx).UpdateBlurHash(artID.ID, hash, version) } return fmt.Errorf("blurhash: no persister for artwork kind %q", artID.Kind) } diff --git a/core/artwork/blurhash_updater_internal_test.go b/core/artwork/blurhash_updater_internal_test.go index 9f95a064c..0fa42eb08 100644 --- a/core/artwork/blurhash_updater_internal_test.go +++ b/core/artwork/blurhash_updater_internal_test.go @@ -38,78 +38,81 @@ func realPNGBytes(label string) []byte { var _ = Describe("blurHashUpdater", func() { var u *blurHashUpdater var ds *tests.MockDataStore + var repo *tests.MockAlbumRepo var version time.Time + album := func(al model.Album) model.ArtworkID { + repo = tests.CreateMockAlbumRepo() + repo.SetData(model.Albums{al}) + ds.MockedAlbum = repo + return al.CoverArtID() + } + stored := func(id string) model.Album { + al, err := ds.Album(GinkgoT().Context()).Get(id) + Expect(err).ToNot(HaveOccurred()) + return *al + } + BeforeEach(func() { ds = &tests.MockDataStore{} - u = &blurHashUpdater{ - a: &artwork{ds: ds}, - buffer: make(map[model.ArtworkID]blurHashJob), - wake: make(chan struct{}, 1), - last: make(map[model.ArtworkID]string), - started: true, - } + u = newBlurHashUpdater(ds) version = time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) }) - It("persists a hash computed from the given bytes", func() { - al := model.Album{ID: "al-1", UpdatedAt: version} - repo := tests.CreateMockAlbumRepo() - repo.SetData(model.Albums{al}) - ds.MockedAlbum = repo - - u.processJob(GinkgoT().Context(), al.CoverArtID(), blurHashJob{data: realPNGBytes("x"), version: version}) - stored, err := ds.Album(GinkgoT().Context()).Get("al-1") - Expect(err).ToNot(HaveOccurred()) - Expect(stored.BlurHash).ToNot(BeEmpty()) + It("persists a hash computed from the served bytes", func() { + id := album(model.Album{ID: "al-1", UpdatedAt: version}) + u.update(GinkgoT().Context(), id, realPNGBytes("x"), version) + al := stored("al-1") + Expect(al.BlurHash).ToNot(BeEmpty()) + Expect(al.BlurHashUpdatedAt).To(HaveValue(Equal(version))) }) - It("clears the hash when the bytes are a placeholder", func() { - al := model.Album{ID: "al-1", UpdatedAt: version, BlurHash: "OLD"} - repo := tests.CreateMockAlbumRepo() - repo.SetData(model.Albums{al}) - ds.MockedAlbum = repo - - u.processJob(GinkgoT().Context(), al.CoverArtID(), blurHashJob{data: placeholderImages()[0], version: version}) - stored, _ := ds.Album(GinkgoT().Context()).Get("al-1") - Expect(stored.BlurHash).To(BeEmpty()) // cleared: placeholder means gone + It("clears the stored hash when the served bytes are a placeholder", func() { + id := album(model.Album{ID: "al-1", UpdatedAt: version, BlurHash: "OLD"}) + u.update(GinkgoT().Context(), id, placeholderImages()[0], version) + Expect(stored("al-1").BlurHash).To(BeEmpty()) }) It("leaves the hash untouched on undecodable bytes", func() { - al := model.Album{ID: "al-1", UpdatedAt: version, BlurHash: "KEEP"} - repo := tests.CreateMockAlbumRepo() - repo.SetData(model.Albums{al}) - ds.MockedAlbum = repo - - u.processJob(GinkgoT().Context(), al.CoverArtID(), blurHashJob{data: []byte("not an image"), version: version}) - stored, _ := ds.Album(GinkgoT().Context()).Get("al-1") - Expect(stored.BlurHash).To(Equal("KEEP")) + id := album(model.Album{ID: "al-1", UpdatedAt: version, BlurHash: "KEEP"}) + u.update(GinkgoT().Context(), id, []byte("not an image"), version) + Expect(stored("al-1").BlurHash).To(Equal("KEEP")) }) - It("skips a redundant write when the hash is unchanged (in-memory dedup)", func() { - al := model.Album{ID: "al-1", UpdatedAt: version} - repo := tests.CreateMockAlbumRepo() - repo.SetData(model.Albums{al}) - ds.MockedAlbum = repo + It("skips the write when the same bytes are served again under the same version", func() { + id := album(model.Album{ID: "al-1", UpdatedAt: version}) data := realPNGBytes("dedup") + u.update(GinkgoT().Context(), id, data, version) + // Tamper with the stored value: a second identical serve must not touch the row. + Expect(repo.UpdateBlurHash("al-1", "TAMPERED", version)).To(Succeed()) + u.update(GinkgoT().Context(), id, data, version) + Expect(stored("al-1").BlurHash).To(Equal("TAMPERED")) + }) - u.processJob(GinkgoT().Context(), al.CoverArtID(), blurHashJob{data: data, version: version}) - first, _ := ds.Album(GinkgoT().Context()).Get("al-1") - u.processJob(GinkgoT().Context(), al.CoverArtID(), blurHashJob{data: data, version: version.Add(time.Hour)}) - second, _ := ds.Album(GinkgoT().Context()).Get("al-1") + It("re-persists the same hash when the artwork version advances", func() { + // A scan can bump the entity version without changing the cover; blur_hash_updated_at must + // follow, or the DTO's staleness gate would emit the fake hash forever after. + id := album(model.Album{ID: "al-1", UpdatedAt: version}) + data := realPNGBytes("same-bytes") + u.update(GinkgoT().Context(), id, data, version) + first := stored("al-1") + newer := version.Add(time.Hour) + u.update(GinkgoT().Context(), id, data, newer) + second := stored("al-1") Expect(second.BlurHash).To(Equal(first.BlurHash)) + Expect(second.BlurHashUpdatedAt).To(HaveValue(Equal(newer))) }) - It("ignores non-eligible artwork kinds on enqueue", func() { - u.EnqueueBytes(model.ArtworkID{Kind: model.KindMediaFileArtwork, ID: "mf-1"}, realPNGBytes("x"), version) - Expect(u.buffer).To(BeEmpty()) + It("does not write when a placeholder is served and nothing was ever stored", func() { + id := album(model.Album{ID: "al-1", UpdatedAt: version}) + u.update(GinkgoT().Context(), id, placeholderImages()[0], version) + Expect(stored("al-1").BlurHashUpdatedAt).To(BeNil()) }) - It("supersedes a pending gone-check with a bytes job", func() { - id := model.Album{ID: "al-1"}.CoverArtID() - u.EnqueueClearIfGone(id, version) - u.EnqueueBytes(id, realPNGBytes("x"), version.Add(time.Hour)) - Expect(u.buffer[id].checkGone).To(BeFalse()) - Expect(u.buffer[id].data).ToNot(BeNil()) + It("ignores non-eligible artwork kinds", func() { + Expect(func() { + u.clearIfStored(GinkgoT().Context(), model.ArtworkID{Kind: model.KindMediaFileArtwork, ID: "mf-1"}, version) + }).ToNot(Panic()) + Expect(u.seen).To(BeEmpty()) }) }) diff --git a/core/artwork/e2e/suite_test.go b/core/artwork/e2e/suite_test.go index 81bec7f52..06cc05b6f 100644 --- a/core/artwork/e2e/suite_test.go +++ b/core/artwork/e2e/suite_test.go @@ -3,7 +3,6 @@ package artworke2e_test import ( "context" "fmt" - "io" "path/filepath" "testing" @@ -58,18 +57,6 @@ var _ = AfterSuite(func() { db.Close(GinkgoT().Context()) }) -// AfterEach runs before any DeferCleanup a spec body registered, so it stops the blurhash worker -// before spec-local TempDirs are removed. On Windows a still-running worker can hold an artwork -// file open, and TempDir removal cannot unlink an open file. -var _ = AfterEach(func() { - if aw == nil { - return - } - if c, ok := aw.(io.Closer); ok { - Expect(c.Close()).To(Succeed()) - } -}) - func setupHarness() { DeferCleanup(configtest.SetupConfig()) @@ -101,8 +88,6 @@ func setupHarness() { storagetest.Register(fakeLibScheme, fakeFS) aw = artwork.NewArtwork(ds, artwork.GetImageCache(), newNoopFFmpeg(), &noopProvider{}) - // The worker is stopped by the suite-level AfterEach (which runs before spec-local TempDir - // cleanups), so it can't outlive the spec or hold a file open past TempDir removal. } func scan() { From 2499de2bac6290576f413cc91e941004d2f603d0 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 17 Jul 2026 21:46:12 -0400 Subject: [PATCH 35/46] perf(artwork): key blurhash dedup by identity, plus review cleanups The seen map was keyed by the full ArtworkID, which embeds the client token's LastUpdate: every scan bump rotated the key, so the same-bytes dedup (and its cheap version re-persist path) never fired in production and stale entries accumulated forever. The key now zeroes LastUpdate, making the map bounded by entity count and restoring the tested dedup behavior. Cleanups from the same review: the base83 encoder is now exported from the blurhash package and the DTO's duplicate copy is deleted; the always-set blurHashes field lost its test-shaped nil guards; clearIfStored dropped its inert version parameter (cleared rows never have their timestamp read); the teeReader done flag is replaced by nilling the callback; worker-era comments (async, cache-miss recompute) were updated to the inline design; and the e2e specs assert directly instead of polling, since the hash is persisted before the read helpers return. --- core/artwork/artwork.go | 14 +-- core/artwork/blurhash/blurhash.go | 14 ++- core/artwork/blurhash_updater.go | 14 ++- .../artwork/blurhash_updater_internal_test.go | 16 ++- core/artwork/e2e/blurhash_test.go | 111 +++++++----------- core/artwork/tee_reader.go | 8 +- model/album.go | 4 +- model/artist.go | 2 +- server/jellyfin/dto/blurhash.go | 25 +--- server/jellyfin/dto/blurhash_test.go | 4 + 10 files changed, 91 insertions(+), 121 deletions(-) diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go index a22cc471d..6fa60c816 100644 --- a/core/artwork/artwork.go +++ b/core/artwork/artwork.go @@ -38,9 +38,7 @@ type Artwork interface { } func NewArtwork(ds model.DataStore, cache cache.FileCache, ffmpeg ffmpeg.FFmpeg, provider external.Provider) Artwork { - a := &artwork{ds: ds, cache: cache, ffmpeg: ffmpeg, provider: provider} - a.blurHashes = newBlurHashUpdater(ds) - return a + return &artwork{ds: ds, cache: cache, ffmpeg: ffmpeg, provider: provider, blurHashes: newBlurHashUpdater(ds)} } type artwork struct { @@ -63,11 +61,9 @@ func (a *artwork) GetOrPlaceholder(ctx context.Context, id string, size int, squ reader, lastUpdate, err = a.Get(ctx, artID, size, square) } if errors.Is(err, ErrUnavailable) { - if a.blurHashes != nil { - // The client is receiving the placeholder, so a stored hash describing the old cover must - // clear — hash-what-you-serve applies to the fallback too. - a.blurHashes.clearIfStored(ctx, artID, time.Now()) - } + // The client is receiving the placeholder, so a stored hash describing the old cover must + // clear — hash-what-you-serve applies to the fallback too. + a.blurHashes.clearIfStored(ctx, artID) if artID.Kind == model.KindArtistArtwork { reader, _ = resources.FS().Open(consts.PlaceholderArtistArt) } else { @@ -92,7 +88,7 @@ func (a *artwork) Get(ctx context.Context, artID model.ArtworkID, size int, squa return nil, time.Time{}, err } reader = r - if a.blurHashes != nil && size == 0 && !square && eligibleKind(artID) { + if size == 0 && !square && eligibleKind(artID) { // Tee the served bytes: the blurhash is computed from exactly what the client downloads, so it // changes precisely when the served cover changes. Placeholder bytes (playlist fallback) clear. // The tee wraps r directly, so Close reaches the underlying stream (no fd leak). diff --git a/core/artwork/blurhash/blurhash.go b/core/artwork/blurhash/blurhash.go index ba466f467..302ec83c0 100644 --- a/core/artwork/blurhash/blurhash.go +++ b/core/artwork/blurhash/blurhash.go @@ -85,7 +85,7 @@ func Encode(img image.Image, xComp, yComp int) (string, error) { } var sb strings.Builder - sb.WriteString(encode83((xComp-1)+(yComp-1)*9, 1)) + sb.WriteString(Encode83((xComp-1)+(yComp-1)*9, 1)) ac := factors[1:] maxVal := 1.0 @@ -96,15 +96,15 @@ func Encode(img image.Image, xComp, yComp int) (string, error) { } quantMax := int(math.Max(0, math.Min(82, math.Floor(actualMax*166-0.5)))) maxVal = float64(quantMax+1) / 166 - sb.WriteString(encode83(quantMax, 1)) + sb.WriteString(Encode83(quantMax, 1)) } else { - sb.WriteString(encode83(0, 1)) + sb.WriteString(Encode83(0, 1)) } dc := factors[0] - sb.WriteString(encode83(linearToSRGB(dc[0])<<16|linearToSRGB(dc[1])<<8|linearToSRGB(dc[2]), 4)) + sb.WriteString(Encode83(linearToSRGB(dc[0])<<16|linearToSRGB(dc[1])<<8|linearToSRGB(dc[2]), 4)) for _, f := range ac { - sb.WriteString(encode83(quantAC(f[0], maxVal)*19*19+quantAC(f[1], maxVal)*19+quantAC(f[2], maxVal), 2)) + sb.WriteString(Encode83(quantAC(f[0], maxVal)*19*19+quantAC(f[1], maxVal)*19+quantAC(f[2], maxVal), 2)) } return sb.String(), nil } @@ -165,7 +165,9 @@ func linearToSRGB(v float64) int { return int((1.055*math.Pow(v, 1/2.4)-0.055)*255 + 0.5) } -func encode83(value, length int) string { +// Encode83 encodes value as a fixed-width, big-endian base83 string of the given length, using the +// blurhash spec's alphabet. +func Encode83(value, length int) string { b := make([]byte, length) for i := length - 1; i >= 0; i-- { b[i] = alphabet[value%83] diff --git a/core/artwork/blurhash_updater.go b/core/artwork/blurhash_updater.go index 0e05206fb..b628605b8 100644 --- a/core/artwork/blurhash_updater.go +++ b/core/artwork/blurhash_updater.go @@ -55,10 +55,13 @@ func (u *blurHashUpdater) update(ctx context.Context, artID model.ArtworkID, dat log.Error(ctx, "BlurHash: recovered from panic", "artID", artID, "panic", r) } }() + // ArtworkID embeds the client token's LastUpdate; without zeroing it the seen key would rotate on + // every scan bump, defeating the same-bytes dedup and stranding stale entries forever. + artID.LastUpdate = time.Time{} // The response is already written when the tee fires; a client abort must not lose the write. ctx = context.WithoutCancel(ctx) if isPlaceholder(data) { - u.clearIfStored(ctx, artID, version) + u.clearIfStored(ctx, artID) return } sum := checksum(data) @@ -91,10 +94,11 @@ func (u *blurHashUpdater) update(ctx context.Context, artID model.ArtworkID, dat // clearIfStored clears the persisted hash after a placeholder was served (a cold map costs one row // read to skip never-hashed entities); a failed read clears nothing — unknown state is not deletion. -func (u *blurHashUpdater) clearIfStored(ctx context.Context, artID model.ArtworkID, version time.Time) { +func (u *blurHashUpdater) clearIfStored(ctx context.Context, artID model.ArtworkID) { if !eligibleKind(artID) { return } + artID.LastUpdate = time.Time{} ctx = context.WithoutCancel(ctx) u.mutex.Lock() prev, ok := u.seen[artID] @@ -108,15 +112,15 @@ func (u *blurHashUpdater) clearIfStored(ctx context.Context, artID model.Artwork return } if stored == "" { - u.remember(artID, blurHashState{version: version}) + u.remember(artID, blurHashState{}) return } } - if err := u.persist(ctx, artID, "", version); err != nil { + if err := u.persist(ctx, artID, "", time.Now()); err != nil { log.Warn(ctx, "BlurHash: error clearing hash", "artID", artID, err) return } - u.remember(artID, blurHashState{version: version}) + u.remember(artID, blurHashState{}) } func (u *blurHashUpdater) persistAndRemember(ctx context.Context, artID model.ArtworkID, hash string, sum uint64, version time.Time) { diff --git a/core/artwork/blurhash_updater_internal_test.go b/core/artwork/blurhash_updater_internal_test.go index 0fa42eb08..c537bb378 100644 --- a/core/artwork/blurhash_updater_internal_test.go +++ b/core/artwork/blurhash_updater_internal_test.go @@ -111,8 +111,22 @@ var _ = Describe("blurHashUpdater", func() { It("ignores non-eligible artwork kinds", func() { Expect(func() { - u.clearIfStored(GinkgoT().Context(), model.ArtworkID{Kind: model.KindMediaFileArtwork, ID: "mf-1"}, version) + u.clearIfStored(GinkgoT().Context(), model.ArtworkID{Kind: model.KindMediaFileArtwork, ID: "mf-1"}) }).ToNot(Panic()) Expect(u.seen).To(BeEmpty()) }) + + It("dedups across artwork ids that differ only in their embedded timestamp", func() { + // Client coverArt tokens embed a LastUpdate; the seen key must ignore it, or every scan bump + // would defeat the dedup and re-decode identical bytes. + id := album(model.Album{ID: "al-1", UpdatedAt: version}) + data := realPNGBytes("dedup") + u.update(GinkgoT().Context(), id, data, version) + Expect(repo.UpdateBlurHash("al-1", "TAMPERED", version)).To(Succeed()) + bumped := id + bumped.LastUpdate = version.Add(time.Hour) + u.update(GinkgoT().Context(), bumped, data, version) + Expect(stored("al-1").BlurHash).To(Equal("TAMPERED")) + Expect(u.seen).To(HaveLen(1)) + }) }) diff --git a/core/artwork/e2e/blurhash_test.go b/core/artwork/e2e/blurhash_test.go index f34c09c15..f78b6fe6f 100644 --- a/core/artwork/e2e/blurhash_test.go +++ b/core/artwork/e2e/blurhash_test.go @@ -16,6 +16,15 @@ var _ = Describe("BlurHash", func() { setupHarness() }) + // The blurhash is computed inline when the served reader is closed, so by the time the read + // helpers return, the hash is already persisted — no polling needed. + storedAlbum := func(id string) model.Album { + GinkgoHelper() + updated, err := ds.Album(ctx).Get(id) + Expect(err).ToNot(HaveOccurred()) + return *updated + } + It("persists a real blurhash after album artwork is served", func() { setLayout(fstest.MapFS{ "Artist/Album/01 - Song.mp3": trackFile(1, "Song"), @@ -25,18 +34,14 @@ var _ = Describe("BlurHash", func() { al := firstAlbum() Expect(al.BlurHash).To(BeEmpty()) - // Serving the artwork enqueues the async blurhash computation. readArtwork(al.CoverArtID()) - Eventually(func(g Gomega) { - updated, err := ds.Album(ctx).Get(al.ID) - g.Expect(err).ToNot(HaveOccurred()) - g.Expect(len(updated.BlurHash)).To(BeNumerically(">", 6)) - g.Expect(updated.BlurHashUpdatedAt).ToNot(BeNil()) - // The snapshot must not be before the artwork version, or the DTO would treat it as - // stale (it may exceed it: image file mtimes are folded in). - g.Expect(updated.BlurHashUpdatedAt.Before(updated.ArtworkUpdatedAt())).To(BeFalse()) - }, "10s", "100ms").Should(Succeed()) + updated := storedAlbum(al.ID) + Expect(len(updated.BlurHash)).To(BeNumerically(">", 6)) + Expect(updated.BlurHashUpdatedAt).ToNot(BeNil()) + // The snapshot must not be before the artwork version, or the DTO would treat it as + // stale (it may exceed it: image file mtimes are folded in). + Expect(updated.BlurHashUpdatedAt.Before(updated.ArtworkUpdatedAt())).To(BeFalse()) }) It("does not persist a future-dated blurhash timestamp", func() { @@ -50,15 +55,12 @@ var _ = Describe("BlurHash", func() { al := firstAlbum() readArtwork(al.CoverArtID()) - Eventually(func(g Gomega) { - updated, err := ds.Album(ctx).Get(al.ID) - g.Expect(err).ToNot(HaveOccurred()) - g.Expect(updated.BlurHash).ToNot(BeEmpty()) - g.Expect(updated.BlurHashUpdatedAt).ToNot(BeNil()) - // A future file mtime must be capped at now, or the !Before checks would pin the hash - // (and the client's cover cache) until wall time caught up. - g.Expect(updated.BlurHashUpdatedAt.After(time.Now())).To(BeFalse()) - }, "10s", "100ms").Should(Succeed()) + updated := storedAlbum(al.ID) + Expect(updated.BlurHash).ToNot(BeEmpty()) + Expect(updated.BlurHashUpdatedAt).ToNot(BeNil()) + // A future file mtime must be capped at now, or the !Before checks would pin the hash + // (and the client's cover cache) until wall time caught up. + Expect(updated.BlurHashUpdatedAt.After(time.Now())).To(BeFalse()) }) It("recomputes when the cover is swapped in place", func() { @@ -69,13 +71,8 @@ var _ = Describe("BlurHash", func() { scan() al := firstAlbum() readArtwork(al.CoverArtID()) - var firstHash string - Eventually(func(g Gomega) { - updated, err := ds.Album(ctx).Get(al.ID) - g.Expect(err).ToNot(HaveOccurred()) - g.Expect(updated.BlurHash).ToNot(BeEmpty()) - firstHash = updated.BlurHash - }, "10s", "100ms").Should(Succeed()) + firstHash := storedAlbum(al.ID).BlurHash + Expect(firstHash).ToNot(BeEmpty()) // Swap the cover bytes and rescan, then serve: the tee hashes the newly-served bytes, so the // stored hash moves to describe the new cover. @@ -86,12 +83,9 @@ var _ = Describe("BlurHash", func() { scan() readArtwork(al.CoverArtID()) - Eventually(func(g Gomega) { - updated, err := ds.Album(ctx).Get(al.ID) - g.Expect(err).ToNot(HaveOccurred()) - g.Expect(updated.BlurHash).ToNot(BeEmpty()) - g.Expect(updated.BlurHash).ToNot(Equal(firstHash)) - }, "10s", "100ms").Should(Succeed()) + updated := storedAlbum(al.ID) + Expect(updated.BlurHash).ToNot(BeEmpty()) + Expect(updated.BlurHash).ToNot(Equal(firstHash)) }) It("clears the stored blurhash when the cover disappears", func() { @@ -102,25 +96,17 @@ var _ = Describe("BlurHash", func() { scan() al := firstAlbum() readArtwork(al.CoverArtID()) - Eventually(func(g Gomega) { - updated, err := ds.Album(ctx).Get(al.ID) - g.Expect(err).ToNot(HaveOccurred()) - g.Expect(updated.BlurHash).ToNot(BeEmpty()) - }, "10s", "100ms").Should(Succeed()) + Expect(storedAlbum(al.ID).BlurHash).ToNot(BeEmpty()) // No rescan: the folder row still lists the cover, but the file is gone. The serve falls back - // to the placeholder (GetOrPlaceholder, the real Jellyfin/Subsonic path), and the worker's - // gone-recheck confirms the source is really gone and clears the stored hash. + // to the placeholder (GetOrPlaceholder, the real Jellyfin/Subsonic path), which clears the + // stored hash inline. setLayout(fstest.MapFS{ "Artist/Album/01 - Song.mp3": trackFile(1, "Song"), }) Expect(readOrPlaceholder(al.CoverArtID())).To(Equal(placeholderBytes())) - Eventually(func(g Gomega) { - updated, err := ds.Album(ctx).Get(al.ID) - g.Expect(err).ToNot(HaveOccurred()) - g.Expect(updated.BlurHash).To(BeEmpty()) - }, "10s", "100ms").Should(Succeed()) + Expect(storedAlbum(al.ID).BlurHash).To(BeEmpty()) }) It("recomputes when cover bytes change under a preserved mtime (cache disabled)", func() { @@ -134,13 +120,8 @@ var _ = Describe("BlurHash", func() { scan() al := firstAlbum() readArtwork(al.CoverArtID()) - var firstHash string - Eventually(func(g Gomega) { - updated, err := ds.Album(ctx).Get(al.ID) - g.Expect(err).ToNot(HaveOccurred()) - g.Expect(updated.BlurHash).ToNot(BeEmpty()) - firstHash = updated.BlurHash - }, "10s", "100ms").Should(Succeed()) + firstHash := storedAlbum(al.ID).BlurHash + Expect(firstHash).ToNot(BeEmpty()) // Replace the bytes but keep the SAME mtime and do NOT rescan: only the served bytes change. swapped := realPNG("swapped-bytes") @@ -151,11 +132,7 @@ var _ = Describe("BlurHash", func() { }) readArtwork(al.CoverArtID()) - Eventually(func(g Gomega) { - updated, err := ds.Album(ctx).Get(al.ID) - g.Expect(err).ToNot(HaveOccurred()) - g.Expect(updated.BlurHash).ToNot(Equal(firstHash)) - }, "10s", "100ms").Should(Succeed()) + Expect(storedAlbum(al.ID).BlurHash).ToNot(Equal(firstHash)) }) It("clears a stored playlist hash when it falls back to the placeholder", func() { @@ -169,21 +146,17 @@ var _ = Describe("BlurHash", func() { pl := putPlaylist(model.Playlist{ID: "pl-blur", Name: "MyList", Path: m3uPath}) readArtwork(pl.CoverArtID()) - Eventually(func(g Gomega) { - updated, err := ds.Playlist(ctx).Get(pl.ID) - g.Expect(err).ToNot(HaveOccurred()) - g.Expect(updated.BlurHash).ToNot(BeEmpty()) - }, "10s", "100ms").Should(Succeed()) + stored, err := ds.Playlist(ctx).Get(pl.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(stored.BlurHash).ToNot(BeEmpty()) // Remove the sidecar: the serve now falls through to the placeholder, captured by the tee. Expect(os.Remove(sidecar)).To(Succeed()) Expect(readArtwork(pl.CoverArtID())).To(Equal(placeholderBytes())) - Eventually(func(g Gomega) { - updated, err := ds.Playlist(ctx).Get(pl.ID) - g.Expect(err).ToNot(HaveOccurred()) - g.Expect(updated.BlurHash).To(BeEmpty()) - }, "10s", "100ms").Should(Succeed()) + stored, err = ds.Playlist(ctx).Get(pl.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(stored.BlurHash).To(BeEmpty()) }) It("does not persist a blurhash when the served image cannot be decoded", func() { @@ -196,10 +169,6 @@ var _ = Describe("BlurHash", func() { readArtwork(al.CoverArtID()) - Consistently(func(g Gomega) { - updated, err := ds.Album(ctx).Get(al.ID) - g.Expect(err).ToNot(HaveOccurred()) - g.Expect(updated.BlurHash).To(BeEmpty()) - }, "600ms", "100ms").Should(Succeed()) + Expect(storedAlbum(al.ID).BlurHash).To(BeEmpty()) }) }) diff --git a/core/artwork/tee_reader.go b/core/artwork/tee_reader.go index e55592ab0..958f43b59 100644 --- a/core/artwork/tee_reader.go +++ b/core/artwork/tee_reader.go @@ -15,7 +15,6 @@ type teeReader struct { onComplete func(data []byte) eof bool over bool - done bool } func newTeeReader(src io.ReadCloser, maxBytes int, onComplete func(data []byte)) *teeReader { @@ -40,9 +39,10 @@ func (t *teeReader) Read(p []byte) (int, error) { func (t *teeReader) Close() error { err := t.src.Close() - if !t.done && t.eof && !t.over && t.onComplete != nil { - t.done = true - t.onComplete(t.buf.Bytes()) + if t.eof && !t.over && t.onComplete != nil { + cb := t.onComplete + t.onComplete = nil // fire at most once, even on double Close + cb(t.buf.Bytes()) } return err } diff --git a/model/album.go b/model/album.go index f4d2fdedb..130cd376f 100644 --- a/model/album.go +++ b/model/album.go @@ -68,7 +68,7 @@ type Album struct { CreatedAt time.Time `structs:"created_at" json:"createdAt"` // Oldest CreatedAt for all songs in this album UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` // Newest UpdatedAt for all songs in this album - // BlurHash of the album cover, computed asynchronously from the served artwork. Excluded from + // BlurHash of the album cover, computed from the served artwork bytes. Excluded from // full-row writes (structs:"-"): only UpdateBlurHash writes it, so scans can't erase it. BlurHash string `structs:"-" json:"blurHash,omitempty" hash:"ignore"` BlurHashUpdatedAt *time.Time `structs:"-" json:"-" hash:"ignore"` @@ -80,7 +80,7 @@ func (a Album) CoverArtID() ArtworkID { // ArtworkUpdatedAt is the album's artwork version. ExternalInfoUpdatedAt is deliberately excluded: // it bumps on every agent TTL refresh even when the image is unchanged, and actual image changes -// are caught by the image-cache-miss recompute instead. +// are caught by hashing the served bytes instead. func (a Album) ArtworkUpdatedAt() time.Time { t := a.UpdatedAt if a.ImportedAt.After(t) { diff --git a/model/artist.go b/model/artist.go index 12b1dc629..baaf1e4b1 100644 --- a/model/artist.go +++ b/model/artist.go @@ -68,7 +68,7 @@ func (a Artist) CoverArtID() ArtworkID { // ArtworkUpdatedAt is the artist's artwork version. ExternalInfoUpdatedAt is deliberately // excluded: it bumps on every agent TTL refresh even when the image is unchanged, and actual -// image changes are caught by the image-cache-miss recompute instead. +// image changes are caught by hashing the served bytes instead. func (a Artist) ArtworkUpdatedAt() time.Time { if a.UpdatedAt == nil { return time.Time{} diff --git a/server/jellyfin/dto/blurhash.go b/server/jellyfin/dto/blurhash.go index d0404d6c3..ddcbeb08c 100644 --- a/server/jellyfin/dto/blurhash.go +++ b/server/jellyfin/dto/blurhash.go @@ -4,29 +4,10 @@ import ( "fmt" "hash/fnv" "time" + + "github.com/navidrome/navidrome/core/artwork/blurhash" ) -// base83Alphabet is the blurhash spec's base83 encoding alphabet; order is part of the spec. -const base83Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~" - -// base83 encodes value as a fixed-width, big-endian base83 string of the given length. -func base83(value, length int) string { - b := make([]byte, length) - for i := 1; i <= length; i++ { - digit := (value / pow83(length-i)) % 83 - b[i-1] = base83Alphabet[digit] - } - return string(b) -} - -func pow83(n int) int { - result := 1 - for range n { - result *= 83 - } - return result -} - // blurHash returns a valid 6-char blurhash for a solid color derived from seed. Finamp only needs a // well-formed, per-tag-stable value (it uses this as a download de-dup key and blur placeholder), so // a solid color unique to the tag satisfies both without decoding cover art. @@ -36,7 +17,7 @@ func blurHash(seed string) string { sum := h.Sum(nil) r, g, b := int(sum[0]), int(sum[1]), int(sum[2]) dc := (r << 16) | (g << 8) | b - return "00" + base83(dc, 4) + return "00" + blurhash.Encode83(dc, 4) } // primaryBlurHash returns the stored blurhash when it was computed from the entity's current diff --git a/server/jellyfin/dto/blurhash_test.go b/server/jellyfin/dto/blurhash_test.go index 5fc74f293..0d35ae83f 100644 --- a/server/jellyfin/dto/blurhash_test.go +++ b/server/jellyfin/dto/blurhash_test.go @@ -8,6 +8,10 @@ import ( . "github.com/onsi/gomega" ) +// base83Alphabet duplicates the spec alphabet on purpose: the test must catch the production copy +// drifting, not drift along with it. +const base83Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~" + var _ = Describe("blurHash", func() { It("returns a 6-char valid blurhash starting with the 1x1 component prefix", func() { h := blurHash("x") From 07af29fbb4e8e79e5fbaa2eac160f917ea2345d3 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 17 Jul 2026 22:14:13 -0400 Subject: [PATCH 36/46] fix(jellyfin): fold folder image mtimes into the album blurhash version An in-place cover-file swap followed by a quick scan updates only the folder's images_updated_at: no tracks are imported, so the album row never moves and ArtworkUpdatedAt() stayed at the old value. The Jellyfin DTO then kept emitting the previous stored blurhash, and clients that key their cover caches on it never refetched the image, so the served-bytes recompute could never run. The album select now surfaces the newest folder images_updated_at (bare-column correlated subquery over json_each(folder_ids), so the datetime decltype survives and scans as time.Time) and ArtworkUpdatedAt() folds it in. Benchmarked on a 96K track production copy: ~90us/page added, no query-plan change; the subquery is a PK point lookup per folder with at most two rows to order. Artist images and playlist sidecars have the same theoretical gap but their timestamps cannot be derived in SQL; they remain a documented follow-up. --- core/artwork/e2e/blurhash_test.go | 29 +++++++++++++++++++ core/artwork/e2e/suite_test.go | 14 +++++++++- model/album.go | 7 +++++ model/album_test.go | 8 ++++++ persistence/album_repository.go | 6 +++- persistence/album_repository_test.go | 42 ++++++++++++++++++++++++++++ 6 files changed, 104 insertions(+), 2 deletions(-) diff --git a/core/artwork/e2e/blurhash_test.go b/core/artwork/e2e/blurhash_test.go index f78b6fe6f..d11171aaf 100644 --- a/core/artwork/e2e/blurhash_test.go +++ b/core/artwork/e2e/blurhash_test.go @@ -135,6 +135,35 @@ var _ = Describe("BlurHash", func() { Expect(storedAlbum(al.ID).BlurHash).ToNot(Equal(firstHash)) }) + It("advances the album artwork version when only the cover file changes (quick scan)", func() { + setLayout(fstest.MapFS{ + "Artist/Album/01 - Song.mp3": trackFile(1, "Song"), + "Artist/Album/cover.png": realPNG("p1-orig"), + }) + scan() + al := firstAlbum() + readArtwork(al.CoverArtID()) + first := storedAlbum(al.ID) + Expect(first.BlurHash).ToNot(BeEmpty()) + Expect(first.BlurHashUpdatedAt.Before(first.ArtworkUpdatedAt())).To(BeFalse()) + + // Replace only the cover and quick-scan: the album row stays untouched while the folder's + // images_updated_at advances the artwork version, so hash-keyed clients refetch. + fakeFS.Add("Artist/Album/cover.png", realPNG("p1-swapped"), time.Now()) + quickScan() + + stale := storedAlbum(al.ID) + Expect(stale.UpdatedAt).To(Equal(first.UpdatedAt), "premise: image-only change must not touch the album row") + Expect(stale.BlurHash).To(Equal(first.BlurHash)) + Expect(stale.BlurHashUpdatedAt.Before(stale.ArtworkUpdatedAt())).To(BeTrue(), "stored hash must read as stale") + + // The refetch serves the new bytes; the tee rotates the hash and its version catches up. + readArtwork(al.CoverArtID()) + fresh := storedAlbum(al.ID) + Expect(fresh.BlurHash).ToNot(Equal(first.BlurHash)) + Expect(fresh.BlurHashUpdatedAt.Before(fresh.ArtworkUpdatedAt())).To(BeFalse()) + }) + It("clears a stored playlist hash when it falls back to the placeholder", func() { // A playlist with a sidecar cover gets a real hash; removing the sidecar makes the reader chain // fall through to fromAlbumPlaceholder(), whose bytes flow through the tee on Get and clear it. diff --git a/core/artwork/e2e/suite_test.go b/core/artwork/e2e/suite_test.go index 06cc05b6f..7e2740c6e 100644 --- a/core/artwork/e2e/suite_test.go +++ b/core/artwork/e2e/suite_test.go @@ -91,10 +91,22 @@ func setupHarness() { } func scan() { + GinkgoHelper() + doScan(true) +} + +// quickScan runs a non-full scan: only outdated folders are processed and unchanged audio files are +// not reimported, so an image-only change reaches the folder row without touching the album row. +func quickScan() { + GinkgoHelper() + doScan(false) +} + +func doScan(full bool) { GinkgoHelper() s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) - _, err := s.ScanAll(ctx, true) + _, err := s.ScanAll(ctx, full) Expect(err).ToNot(HaveOccurred()) } diff --git a/model/album.go b/model/album.go index 130cd376f..16a093393 100644 --- a/model/album.go +++ b/model/album.go @@ -72,6 +72,10 @@ type Album struct { // full-row writes (structs:"-"): only UpdateBlurHash writes it, so scans can't erase it. BlurHash string `structs:"-" json:"blurHash,omitempty" hash:"ignore"` BlurHashUpdatedAt *time.Time `structs:"-" json:"-" hash:"ignore"` + + // FolderImagesUpdatedAt is the newest images_updated_at among the album's folders (selected, not + // persisted): an in-place cover-file swap moves it even though the album row stays untouched. + FolderImagesUpdatedAt *time.Time `structs:"-" json:"-" hash:"ignore"` } func (a Album) CoverArtID() ArtworkID { @@ -86,6 +90,9 @@ func (a Album) ArtworkUpdatedAt() time.Time { if a.ImportedAt.After(t) { t = a.ImportedAt } + if a.FolderImagesUpdatedAt != nil && a.FolderImagesUpdatedAt.After(t) { + t = *a.FolderImagesUpdatedAt + } return t } diff --git a/model/album_test.go b/model/album_test.go index 9dcf2353e..d9f7ff624 100644 --- a/model/album_test.go +++ b/model/album_test.go @@ -69,4 +69,12 @@ var _ = Describe("Album.ArtworkUpdatedAt", func() { al := Album{UpdatedAt: base, ImportedAt: later, ExternalInfoUpdatedAt: &latest} Expect(al.ArtworkUpdatedAt()).To(Equal(later)) }) + It("returns FolderImagesUpdatedAt when it is the newest (in-place cover swap)", func() { + al := Album{UpdatedAt: base, ImportedAt: later, FolderImagesUpdatedAt: &latest} + Expect(al.ArtworkUpdatedAt()).To(Equal(latest)) + }) + It("ignores an older FolderImagesUpdatedAt", func() { + al := Album{UpdatedAt: later, ImportedAt: base, FolderImagesUpdatedAt: &base} + Expect(al.ArtworkUpdatedAt()).To(Equal(later)) + }) }) diff --git a/persistence/album_repository.go b/persistence/album_repository.go index cd5e3f1c0..144e860de 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -224,7 +224,11 @@ func (r *albumRepository) UpdateExternalInfo(al *model.Album) error { } func (r *albumRepository) selectAlbum(options ...model.QueryOptions) SelectBuilder { - sql := r.newSelect(options...).Columns("album.*", "library.path as library_path", "library.name as library_name"). + sql := r.newSelect(options...).Columns("album.*", "library.path as library_path", "library.name as library_name", + // Folds folder image mtimes into the artwork version: an in-place cover swap moves them without + // touching the album row. Bare column (not max()) keeps the decltype so the driver scans time.Time. + "(select f.images_updated_at from folder f, json_each(album.folder_ids) je where f.id = je.value"+ + " order by f.images_updated_at desc limit 1) as folder_images_updated_at"). LeftJoin("library on album.library_id = library.id") sql = r.withAnnotation(sql, "album.id") return r.applyLibraryFilter(sql) diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index c8a66f8b0..7effeca0b 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -900,6 +900,48 @@ func _p(id, name string, sortName ...string) model.Participant { return p } +var _ = Describe("AlbumRepository folder images version", func() { + var repo model.AlbumRepository + + BeforeEach(func() { + ctx := request.WithUser(GinkgoT().Context(), model.User{ID: "userid", UserName: "johndoe"}) + repo = NewAlbumRepository(ctx, GetDBXBuilder()) + var origFolderIDs string + Expect(GetDBXBuilder().NewQuery("select folder_ids from album where id = '103'"). + Row(&origFolderIDs)).To(Succeed()) + DeferCleanup(func() { + _, err := GetDBXBuilder().NewQuery("delete from folder where id = 'fold-blur-1'").Execute() + Expect(err).ToNot(HaveOccurred()) + _, err = GetDBXBuilder().NewQuery("update album set folder_ids = {:f} where id = '103'"). + Bind(map[string]any{"f": origFolderIDs}).Execute() + Expect(err).ToNot(HaveOccurred()) + }) + }) + + It("surfaces the newest folder images_updated_at on the selected album", func() { + // Newer than any fixture row timestamp, so it must win as the artwork version. + imagesAt := time.Date(2030, 6, 1, 12, 0, 0, 0, time.UTC) + _, err := GetDBXBuilder().NewQuery( + "insert into folder (id, library_id, path, name, images_updated_at) values ('fold-blur-1', 1, '.', 'Radioactivity', {:t})"). + Bind(map[string]any{"t": imagesAt}).Execute() + Expect(err).ToNot(HaveOccurred()) + _, err = GetDBXBuilder().NewQuery(`update album set folder_ids = '["fold-blur-1"]' where id = '103'`).Execute() + Expect(err).ToNot(HaveOccurred()) + + al, err := repo.Get("103") + Expect(err).ToNot(HaveOccurred()) + Expect(al.FolderImagesUpdatedAt).ToNot(BeNil()) + Expect(al.FolderImagesUpdatedAt.Equal(imagesAt)).To(BeTrue()) + Expect(al.ArtworkUpdatedAt().Equal(imagesAt)).To(BeTrue(), "folder image changes must advance the artwork version") + }) + + It("leaves FolderImagesUpdatedAt nil when the album has no folders", func() { + al, err := repo.Get("101") + Expect(err).ToNot(HaveOccurred()) + Expect(al.FolderImagesUpdatedAt).To(BeNil()) + }) +}) + var _ = Describe("AlbumRepository.UpdateBlurHash", func() { var repo model.AlbumRepository From 9a360470969780b5f16b3088d00e5e60a049aa32 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 17 Jul 2026 22:30:38 -0400 Subject: [PATCH 37/46] fix(artwork): version the resized cache key to backfill blurhashes on upgrade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resized entries cached by a pre-blurhash version serve straight from the cache: the resized reader never runs, the original is never read, and the tee never gets a chance to compute a hash — clients that only request sized images (Jellyfin maxwidth) would keep receiving the fake blurhash indefinitely for those items. Versioning the resized cache key makes every post-upgrade sized request miss and refill. The refill pulls the original through Get, which usually hits the cached original (original keys are unchanged), so the backfill costs one decode+re-encode per resized variant with no source or external-provider I/O. Orphaned entries are evicted by the cache's LRU as usual. --- core/artwork/reader_resized.go | 6 +++++- core/artwork/reader_resized_test.go | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/core/artwork/reader_resized.go b/core/artwork/reader_resized.go index cd16cbada..0cbe6abd0 100644 --- a/core/artwork/reader_resized.go +++ b/core/artwork/reader_resized.go @@ -66,8 +66,12 @@ func resizedFromOriginal(ctx context.Context, a *artwork, artID model.ArtworkID, return r, nil } +// resizedKeyVersion invalidates resized entries cached by pre-blurhash versions: the refill is what +// pulls the original through the tee, so warm entries would otherwise never backfill a hash. +const resizedKeyVersion = "v1" + func (a *resizedArtworkReader) Key() string { - baseKey := fmt.Sprintf("%s.%d", a.cacheKey, a.size) + baseKey := fmt.Sprintf("%s.%d.%s", a.cacheKey, a.size, resizedKeyVersion) if a.square { return baseKey + ".square" } diff --git a/core/artwork/reader_resized_test.go b/core/artwork/reader_resized_test.go index 7c14f5e44..6b419c8bc 100644 --- a/core/artwork/reader_resized_test.go +++ b/core/artwork/reader_resized_test.go @@ -6,12 +6,28 @@ import ( "errors" "io" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) +var _ = Describe("resizedArtworkReader.Key", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.CoverArtQuality = 75 + }) + + It("includes the cache version so pre-blurhash resized entries are invalidated", func() { + r := &resizedArtworkReader{cacheKey: "al-1.123", size: 300} + Expect(r.Key()).To(Equal("al-1.123.300.v1.75")) + r.square = true + Expect(r.Key()).To(Equal("al-1.123.300.v1.square")) + }) +}) + var _ = Describe("resizeImage", func() { var mockFF *tests.MockFFmpeg var r *resizedArtworkReader From 08d83a7785bdb60d970fba1c03b8203b5584739d Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 17 Jul 2026 22:47:42 -0400 Subject: [PATCH 38/46] fix(persistence): include parent folders in the album artwork version Multi-disc albums keep their cover in the album-root folder, which the artwork reader reaches via albumRootParent but which is not in album.folder_ids (only the disc folders hold media files). A root cover swap therefore advanced the served cache key without moving the selected artwork version, recreating the stale-hash deadlock for hash-keyed clients. The version subquery now also considers the folders' parents. This slightly over-covers (an artist-folder image change can advance a single-folder album's version), which errs on the side of one spurious refetch instead of permanent staleness. Plan verified on a 96K-track production copy: still PK point lookups, outer scan unchanged. --- persistence/album_repository.go | 7 +++++-- persistence/album_repository_test.go | 19 ++++++++++++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 144e860de..4217e2cbc 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -226,8 +226,11 @@ func (r *albumRepository) UpdateExternalInfo(al *model.Album) error { func (r *albumRepository) selectAlbum(options ...model.QueryOptions) SelectBuilder { sql := r.newSelect(options...).Columns("album.*", "library.path as library_path", "library.name as library_name", // Folds folder image mtimes into the artwork version: an in-place cover swap moves them without - // touching the album row. Bare column (not max()) keeps the decltype so the driver scans time.Time. - "(select f.images_updated_at from folder f, json_each(album.folder_ids) je where f.id = je.value"+ + // touching the album row. Parents are included for disc-subfolder layouts, where the reader can + // serve the album-root cover. Bare column (not max()) keeps the decltype so time.Time scans. + "(select f.images_updated_at from folder f where f.id in"+ + " (select je.value from json_each(album.folder_ids) je"+ + " union select p.parent_id from folder p, json_each(album.folder_ids) je2 where p.id = je2.value)"+ " order by f.images_updated_at desc limit 1) as folder_images_updated_at"). LeftJoin("library on album.library_id = library.id") sql = r.withAnnotation(sql, "album.id") diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index 7effeca0b..05978af23 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -910,7 +910,7 @@ var _ = Describe("AlbumRepository folder images version", func() { Expect(GetDBXBuilder().NewQuery("select folder_ids from album where id = '103'"). Row(&origFolderIDs)).To(Succeed()) DeferCleanup(func() { - _, err := GetDBXBuilder().NewQuery("delete from folder where id = 'fold-blur-1'").Execute() + _, err := GetDBXBuilder().NewQuery("delete from folder where id in ('fold-blur-1', 'fold-blur-root')").Execute() Expect(err).ToNot(HaveOccurred()) _, err = GetDBXBuilder().NewQuery("update album set folder_ids = {:f} where id = '103'"). Bind(map[string]any{"f": origFolderIDs}).Execute() @@ -935,6 +935,23 @@ var _ = Describe("AlbumRepository folder images version", func() { Expect(al.ArtworkUpdatedAt().Equal(imagesAt)).To(BeTrue(), "folder image changes must advance the artwork version") }) + It("includes the parent folder's images (album-root cover with disc subfolders)", func() { + discAt := time.Date(2030, 6, 1, 12, 0, 0, 0, time.UTC) + rootAt := discAt.Add(time.Hour) // the root cover is the newest image + _, err := GetDBXBuilder().NewQuery( + "insert into folder (id, library_id, path, name, parent_id, images_updated_at) values" + + " ('fold-blur-root', 1, '.', 'Album', '', {:root}), ('fold-blur-1', 1, './Album', 'CD1', 'fold-blur-root', {:disc})"). + Bind(map[string]any{"root": rootAt, "disc": discAt}).Execute() + Expect(err).ToNot(HaveOccurred()) + _, err = GetDBXBuilder().NewQuery(`update album set folder_ids = '["fold-blur-1"]' where id = '103'`).Execute() + Expect(err).ToNot(HaveOccurred()) + + al, err := repo.Get("103") + Expect(err).ToNot(HaveOccurred()) + Expect(al.FolderImagesUpdatedAt).ToNot(BeNil()) + Expect(al.FolderImagesUpdatedAt.Equal(rootAt)).To(BeTrue(), "the parent folder's newer cover must win") + }) + It("leaves FolderImagesUpdatedAt nil when the album has no folders", func() { al, err := repo.Get("101") Expect(err).ToNot(HaveOccurred()) From f0fe070ba1d9f45ea08b0c2abf29cd7c2f5e1ca8 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 17 Jul 2026 22:47:43 -0400 Subject: [PATCH 39/46] fix(scanner): cap folder images_updated_at at scan time A future-stamped image file (clock skew on NAS mounts) previously flowed verbatim into folder.images_updated_at and from there into the album artwork version, while the stored blur_hash_updated_at is capped at now on write. The DTO staleness gate then kept emitting the fake blurhash until wall time caught up with the file mtime. Capping at the source keeps future values out of the DB entirely; rotation is preserved because any later change is capped to a later scan time. Capping in the DTO instead would be worse: the version would become a moving target and the fake seed would rotate on every request. --- core/artwork/e2e/blurhash_test.go | 3 +++ scanner/walk_dir_tree.go | 9 ++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/core/artwork/e2e/blurhash_test.go b/core/artwork/e2e/blurhash_test.go index d11171aaf..f40e83820 100644 --- a/core/artwork/e2e/blurhash_test.go +++ b/core/artwork/e2e/blurhash_test.go @@ -61,6 +61,9 @@ var _ = Describe("BlurHash", func() { // A future file mtime must be capped at now, or the !Before checks would pin the hash // (and the client's cover cache) until wall time caught up. Expect(updated.BlurHashUpdatedAt.After(time.Now())).To(BeFalse()) + // The scanner caps the folder's images_updated_at too, so the artwork version is not future + // and the freshly computed hash is accepted by the DTO instead of the fake. + Expect(updated.BlurHashUpdatedAt.Before(updated.ArtworkUpdatedAt())).To(BeFalse()) }) It("recomputes when the cover is swapped in place", func() { diff --git a/scanner/walk_dir_tree.go b/scanner/walk_dir_tree.go index 887344b1b..b02ec0dde 100644 --- a/scanner/walk_dir_tree.go +++ b/scanner/walk_dir_tree.go @@ -9,6 +9,7 @@ import ( "slices" "sort" "strings" + "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core/storage" @@ -160,7 +161,13 @@ func loadDir(ctx context.Context, job *scanJob, dirPath string, checker *IgnoreC folder.numPlaylists++ case model.IsImageFile(name): folder.imageFiles[entry.Name()] = entry - folder.imagesUpdatedAt = utils.TimeNewest(folder.imagesUpdatedAt, fileInfo.ModTime(), folder.modTime) + imagesAt := utils.TimeNewest(folder.imagesUpdatedAt, fileInfo.ModTime(), folder.modTime) + // Cap at now: a future-stamped image (clock skew) would otherwise become a future artwork + // version that pins the emitted blurhash to the fake until wall time caught up. + if now := time.Now(); imagesAt.After(now) { + imagesAt = now + } + folder.imagesUpdatedAt = imagesAt } } } From 817baa5c1a27b9f6f78ee480afa8347ea57d670a Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 17 Jul 2026 23:28:01 -0400 Subject: [PATCH 40/46] fix(jellyfin): omit the blurhash when no current hash exists, never fabricate Upstream Jellyfin omits ImageBlurHashes entries when no hash was computed, and clients are built around that: redesign Finamp uses the blurhash as immutable cover identity (year-long cache pins, download dedup) and falls back to id-keyed caching with a short TTL when it is absent. Emitting a rotating fake seeded by id+version fed a fabricated identity into those caches and churned them on every version bump, and a fake accepted under an imprecise artwork version could pin a wrong value for a year. Absence is strictly safer: no LQIP during the first-serve gap, self-healing within the fallback TTL. The staleness gate is kept, its job now being to suppress a stale stored hash rather than to choose between real and fake. Songs no longer carry a fabricated per-album value either; art resolution uses AlbumPrimaryImageTag alone. --- server/jellyfin/dto/blurhash.go | 35 +++++++---------- server/jellyfin/dto/blurhash_test.go | 56 ++++++++------------------- server/jellyfin/dto/mappers.go | 10 ++--- server/jellyfin/dto/mappers_test.go | 27 ++++++------- server/jellyfin/e2e/playlists_test.go | 6 ++- 5 files changed, 51 insertions(+), 83 deletions(-) diff --git a/server/jellyfin/dto/blurhash.go b/server/jellyfin/dto/blurhash.go index ddcbeb08c..8952ab3d9 100644 --- a/server/jellyfin/dto/blurhash.go +++ b/server/jellyfin/dto/blurhash.go @@ -1,32 +1,23 @@ package dto import ( - "fmt" - "hash/fnv" "time" - - "github.com/navidrome/navidrome/core/artwork/blurhash" ) -// blurHash returns a valid 6-char blurhash for a solid color derived from seed. Finamp only needs a -// well-formed, per-tag-stable value (it uses this as a download de-dup key and blur placeholder), so -// a solid color unique to the tag satisfies both without decoding cover art. -func blurHash(seed string) string { - h := fnv.New32a() - _, _ = h.Write([]byte(seed)) - sum := h.Sum(nil) - r, g, b := int(sum[0]), int(sum[1]), int(sum[2]) - dc := (r << 16) | (g << 8) | b - return "00" + blurhash.Encode83(dc, 4) -} - -// primaryBlurHash returns the stored blurhash when it was computed from the entity's current -// artwork version or later (the snapshot folds in image file mtimes, which can exceed row -// timestamps); otherwise a fake seeded by id+version, so the value still rotates on any artwork -// change (Finamp keys its cover caches by this value; tags never reach its image URLs). -func primaryBlurHash(stored string, storedAt *time.Time, id string, version time.Time) string { +// primaryBlurHash returns the stored blurhash when current for the artwork version, else "" so the +// key is omitted (upstream behavior); clients treat it as cover identity, so absence beats a fake. +func primaryBlurHash(stored string, storedAt *time.Time, version time.Time) string { if stored != "" && storedAt != nil && !storedAt.Before(version) { return stored } - return blurHash(fmt.Sprintf("%s-%x", id, version.UnixMilli())) + return "" +} + +// primaryBlurHashes builds the ImageBlurHashes map for a known-current hash, or nil so the field is +// omitted entirely when there is none. +func primaryBlurHashes(tag, hash string) map[string]map[string]string { + if hash == "" { + return nil + } + return map[string]map[string]string{"Primary": {tag: hash}} } diff --git a/server/jellyfin/dto/blurhash_test.go b/server/jellyfin/dto/blurhash_test.go index 0d35ae83f..a661800f9 100644 --- a/server/jellyfin/dto/blurhash_test.go +++ b/server/jellyfin/dto/blurhash_test.go @@ -1,63 +1,41 @@ package dto import ( - "strings" "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) -// base83Alphabet duplicates the spec alphabet on purpose: the test must catch the production copy -// drifting, not drift along with it. -const base83Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~" - -var _ = Describe("blurHash", func() { - It("returns a 6-char valid blurhash starting with the 1x1 component prefix", func() { - h := blurHash("x") - Expect(h).To(HaveLen(6)) - Expect(h).To(HavePrefix("00")) - for _, c := range h { - Expect(strings.ContainsRune(base83Alphabet, c)).To(BeTrue(), "unexpected char %q", c) - } - }) - - It("is deterministic for the same seed", func() { - Expect(blurHash("cover-tag-1")).To(Equal(blurHash("cover-tag-1"))) - }) - - It("differs for different seeds", func() { - Expect(blurHash("cover-tag-1")).ToNot(Equal(blurHash("cover-tag-2"))) - }) -}) - var _ = Describe("primaryBlurHash", func() { version := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) It("returns the stored hash when it matches the current artwork version", func() { - Expect(primaryBlurHash("LEHV6nWB2yk8", &version, "id-1", version)).To(Equal("LEHV6nWB2yk8")) + Expect(primaryBlurHash("LEHV6nWB2yk8", &version, version)).To(Equal("LEHV6nWB2yk8")) }) It("returns the stored hash when the snapshot is newer than the version (image mtime)", func() { newer := version.Add(time.Hour) - Expect(primaryBlurHash("LEHV6nWB2yk8", &newer, "id-1", version)).To(Equal("LEHV6nWB2yk8")) + Expect(primaryBlurHash("LEHV6nWB2yk8", &newer, version)).To(Equal("LEHV6nWB2yk8")) }) - It("falls back to a fake when there is no stored hash", func() { - h := primaryBlurHash("", nil, "id-1", version) - Expect(h).To(HaveLen(6)) + It("omits when there is no stored hash", func() { + Expect(primaryBlurHash("", nil, version)).To(BeEmpty()) }) - It("falls back to a fake when the stored hash is stale", func() { + It("omits when the stored hash is stale (cover changed, not yet re-served)", func() { stale := version.Add(-time.Hour) - h := primaryBlurHash("LEHV6nWB2yk8", &stale, "id-1", version) - Expect(h).To(HaveLen(6)) - Expect(h).ToNot(Equal("LEHV6nWB2yk8")) - }) - - It("rotates the fake when the artwork version moves", func() { - h1 := primaryBlurHash("", nil, "id-1", version) - h2 := primaryBlurHash("", nil, "id-1", version.Add(time.Hour)) - Expect(h1).ToNot(Equal(h2)) + Expect(primaryBlurHash("LEHV6nWB2yk8", &stale, version)).To(BeEmpty()) + }) +}) + +var _ = Describe("primaryBlurHashes", func() { + It("wraps a hash under the Primary tag", func() { + Expect(primaryBlurHashes("tag-1", "LEHV6nWB2yk8")).To( + Equal(map[string]map[string]string{"Primary": {"tag-1": "LEHV6nWB2yk8"}})) + }) + + It("returns nil when there is no hash, so the field is omitted", func() { + Expect(primaryBlurHashes("tag-1", "")).To(BeNil()) }) }) diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index e284ad0de..0ee1c7073 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -179,10 +179,10 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { } else if mf.Genre != "" { item.Genres = []string{mf.Genre} } - // Finamp resolves song art via AlbumId + a non-empty AlbumPrimaryImageTag. + // Finamp resolves song art via AlbumId + a non-empty AlbumPrimaryImageTag. No blurhash for songs: + // there is no stored hash of their own, and clients cache covers by the value as identity. if mf.AlbumID != "" { item.AlbumPrimaryImageTag = mf.AlbumID - item.ImageBlurHashes = map[string]map[string]string{"Primary": {mf.AlbumID: blurHash(mf.AlbumID)}} } return item } @@ -201,7 +201,7 @@ func AlbumToBaseItem(al model.Album) BaseItemDto { RunTimeTicks: TicksFromSeconds(al.Duration), DateCreated: jellyfinDate(&al.CreatedAt), ImageTags: map[string]string{"Primary": al.ID}, - ImageBlurHashes: map[string]map[string]string{"Primary": {al.ID: primaryBlurHash(al.BlurHash, al.BlurHashUpdatedAt, al.ID, al.ArtworkUpdatedAt())}}, + ImageBlurHashes: primaryBlurHashes(al.ID, primaryBlurHash(al.BlurHash, al.BlurHashUpdatedAt, al.ArtworkUpdatedAt())), BackdropImageTags: []string{}, UserData: UserData(al.Annotations, al.ID), } @@ -231,7 +231,7 @@ func ArtistToBaseItem(ar model.Artist) BaseItemDto { SongCount: new(ar.SongCount), DateCreated: jellyfinDate(ar.CreatedAt), ImageTags: map[string]string{"Primary": ar.ID}, - ImageBlurHashes: map[string]map[string]string{"Primary": {ar.ID: primaryBlurHash(ar.BlurHash, ar.BlurHashUpdatedAt, ar.ID, ar.ArtworkUpdatedAt())}}, + ImageBlurHashes: primaryBlurHashes(ar.ID, primaryBlurHash(ar.BlurHash, ar.BlurHashUpdatedAt, ar.ArtworkUpdatedAt())), BackdropImageTags: []string{}, UserData: UserData(ar.Annotations, ar.ID), } @@ -264,7 +264,7 @@ func PlaylistToBaseItem(p model.Playlist) BaseItemDto { ChildCount: new(p.SongCount), RunTimeTicks: TicksFromSeconds(p.Duration), ImageTags: map[string]string{"Primary": tag}, - ImageBlurHashes: map[string]map[string]string{"Primary": {tag: primaryBlurHash(p.BlurHash, p.BlurHashUpdatedAt, p.ID, p.ArtworkUpdatedAt())}}, + ImageBlurHashes: primaryBlurHashes(tag, primaryBlurHash(p.BlurHash, p.BlurHashUpdatedAt, p.ArtworkUpdatedAt())), BackdropImageTags: []string{}, UserData: UserData(p.Annotations, p.ID), } diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index fbd3b16bd..d91d9506e 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -33,8 +33,9 @@ var _ = Describe("mappers", func() { Expect(item.UserData.Played).To(BeTrue()) Expect(item.UserData.Key).To(Equal(EncodeID("song-1"))) Expect(item.UserData.ItemId).To(Equal(EncodeID("song-1"))) - Expect(item.ImageBlurHashes["Primary"]).To(HaveKey(item.AlbumPrimaryImageTag)) - Expect(item.ImageBlurHashes["Primary"][item.AlbumPrimaryImageTag]).To(HaveLen(6)) + Expect(item.AlbumPrimaryImageTag).To(Equal("alb-1")) + // Songs never carry a fabricated blurhash; clients cache covers by it as identity. + Expect(item.ImageBlurHashes).To(BeNil()) }) Describe("Fields gating (matches real Jellyfin)", func() { @@ -209,8 +210,8 @@ var _ = Describe("mappers", func() { Expect(item.ArtistItems).To(Equal(item.AlbumArtists)) Expect(*item.ProductionYear).To(Equal(1999)) Expect(*item.ChildCount).To(Equal(10)) - Expect(item.ImageBlurHashes["Primary"]).To(HaveKey(item.ImageTags["Primary"])) - Expect(item.ImageBlurHashes["Primary"][item.ImageTags["Primary"]]).To(HaveLen(6)) + // No stored blurhash: the field is omitted, never fabricated. + Expect(item.ImageBlurHashes).To(BeNil()) }) It("maps an artist to a MusicArtist folder item", func() { @@ -283,19 +284,18 @@ var _ = Describe("mappers", func() { Expect(*item.UserData.Rating).To(Equal(8.0)) tag := item.ImageTags["Primary"] Expect(tag).ToNot(BeEmpty()) - Expect(item.ImageBlurHashes["Primary"]).To(HaveKey(tag)) - Expect(item.ImageBlurHashes["Primary"][tag]).To(HaveLen(6)) + // No stored blurhash on this playlist: omitted, never fabricated. + Expect(item.ImageBlurHashes).To(BeNil()) }) - It("changes the playlist image tag and blurhash when the playlist is updated (cover upload)", func() { + It("changes the playlist image tag when the playlist is updated (cover upload)", func() { p := model.Playlist{ID: "pl-1", Name: "Chill", UpdatedAt: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)} before := PlaylistToBaseItem(p) p.UpdatedAt = time.Date(2026, 7, 2, 0, 0, 0, 0, time.UTC) after := PlaylistToBaseItem(p) - // Finamp caches covers keyed by blurHash, so tag and blurhash must change with the cover. + // The tag is the cover cache-buster: it must rotate when the playlist (cover) is updated. Expect(after.ImageTags["Primary"]).ToNot(Equal(before.ImageTags["Primary"])) - Expect(after.ImageBlurHashes["Primary"]).ToNot(Equal(before.ImageBlurHashes["Primary"])) }) It("keeps the playlist image tag stable when nothing changed", func() { @@ -394,17 +394,14 @@ var _ = Describe("LyricDtoFromLyrics", func() { var _ = Describe("stored blurhashes", func() { version := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) - It("emits the stored album blurhash when fresh, and a rotating fake when stale", func() { + It("emits the stored album blurhash when fresh, and omits it when stale", func() { al := model.Album{ID: "al-1", Name: "A", UpdatedAt: version, ImportedAt: version, BlurHash: "LEHV6nWB2yk8", BlurHashUpdatedAt: &version} Expect(AlbumToBaseItem(al).ImageBlurHashes["Primary"]["al-1"]).To(Equal("LEHV6nWB2yk8")) al.UpdatedAt = version.Add(time.Hour) // artwork version moved; stored hash is now stale - fake := AlbumToBaseItem(al).ImageBlurHashes["Primary"]["al-1"] - Expect(fake).To(HaveLen(6)) - - al.UpdatedAt = version.Add(2 * time.Hour) - Expect(AlbumToBaseItem(al).ImageBlurHashes["Primary"]["al-1"]).ToNot(Equal(fake)) + Expect(AlbumToBaseItem(al).ImageBlurHashes).To(BeNil(), + "a stale hash must be suppressed, not emitted or replaced by a fake") }) It("emits the stored artist blurhash when fresh", func() { diff --git a/server/jellyfin/e2e/playlists_test.go b/server/jellyfin/e2e/playlists_test.go index d2ff49db9..b06834fc6 100644 --- a/server/jellyfin/e2e/playlists_test.go +++ b/server/jellyfin/e2e/playlists_test.go @@ -217,7 +217,7 @@ var _ = Describe("Playlists", func() { // Guards the whole chain: SetImage must go through a full Put (which bumps UpdatedAt), and the // tag must be versioned by it, or clients keep their blurhash-keyed cover cache forever. - It("rotates the playlist's image tag and blurhash after a cover upload", func() { + It("rotates the playlist's image tag after a cover upload", func() { plID := createPlaylist("Cover Tag", nil) imageTag := func() string { q := queryResult(get("/Items?ids=" + enc(plID))) @@ -233,8 +233,10 @@ var _ = Describe("Playlists", func() { after := imageTag() Expect(after).ToNot(Equal(before)) + // The stored hash (if any) is stale for the new cover, so no blurhash is emitted — clients + // fall back to tag-keyed caching until the new cover is served and re-hashed. q := queryResult(get("/Items?ids=" + enc(plID))) - Expect(q.Items[0].ImageBlurHashes["Primary"]).To(HaveKey(after)) + Expect(q.Items[0].ImageBlurHashes).To(BeEmpty()) }) }) From a98bebc31497946340bc856d453027c2af39317c Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 17 Jul 2026 23:28:02 -0400 Subject: [PATCH 41/46] fix(artwork): clamp the persisted blurhash version to the entity's The read-side artwork version may over-approximate the served sources (folder parents for disc layouts), which with omission semantics suppresses a perfectly valid stored hash. At persist time the hash is fresh for the served bytes by construction, so the write now loads the entity and clamps blur_hash_updated_at up to its current ArtworkUpdatedAt: after any serve the DTO accepts the stored hash, and every omission window closes regardless of read-side precision. The in-memory state shrinks to a pure decode cache (checksum -> hash); staleness decisions moved to the row read, which also lets a drifted stored value be restored from the served bytes. --- core/artwork/blurhash_updater.go | 82 ++++++++++--------- .../artwork/blurhash_updater_internal_test.go | 38 +++++---- 2 files changed, 67 insertions(+), 53 deletions(-) diff --git a/core/artwork/blurhash_updater.go b/core/artwork/blurhash_updater.go index b628605b8..04a03ad09 100644 --- a/core/artwork/blurhash_updater.go +++ b/core/artwork/blurhash_updater.go @@ -15,14 +15,14 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/resources" + "github.com/navidrome/navidrome/utils" ) -// blurHashState remembers what was last persisted for an artwork, keyed by a checksum of the served -// bytes, so repeated serves of the same image skip the decode and the write entirely. +// blurHashState is a decode cache: the hash last computed for an artwork's served bytes, keyed by +// their checksum, so repeated serves of the same image skip the decode. type blurHashState struct { - sum uint64 - version time.Time - hash string + sum uint64 + hash string } // blurHashUpdater keeps stored blurhashes in sync with the bytes actually served. It runs inline in @@ -55,8 +55,7 @@ func (u *blurHashUpdater) update(ctx context.Context, artID model.ArtworkID, dat log.Error(ctx, "BlurHash: recovered from panic", "artID", artID, "panic", r) } }() - // ArtworkID embeds the client token's LastUpdate; without zeroing it the seen key would rotate on - // every scan bump, defeating the same-bytes dedup and stranding stale entries forever. + // ArtworkID embeds the client token's LastUpdate; zero it so the decode cache keys by identity. artID.LastUpdate = time.Time{} // The response is already written when the tee fires; a client abort must not lose the write. ctx = context.WithoutCancel(ctx) @@ -65,31 +64,36 @@ func (u *blurHashUpdater) update(ctx context.Context, artID model.ArtworkID, dat return } sum := checksum(data) - u.mutex.Lock() - prev, ok := u.seen[artID] - u.mutex.Unlock() - if ok && prev.hash != "" && prev.sum == sum { - if !version.After(prev.version) { + hash := u.cachedHash(artID, sum) + if hash == "" { + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + // Undecodable served bytes are not proof of change; leave the stored hash intact. + log.Trace(ctx, "BlurHash: served bytes not decodable, keeping stored hash", "artID", artID, err) + return + } + b := img.Bounds() + x, y := blurhash.Components(b.Dx(), b.Dy()) + if hash, err = blurhash.Encode(img, x, y); err != nil || hash == "" { return } - // Same bytes under a newer artwork version: re-persist so blur_hash_updated_at keeps pace with - // the entity version, or the DTO staleness gate would emit the fake after any routine scan. - u.persistAndRemember(ctx, artID, prev.hash, sum, version) - return } - img, _, err := image.Decode(bytes.NewReader(data)) + stored, storedAt, entityVersion, err := u.loadState(ctx, artID) if err != nil { - // Undecodable served bytes are not proof of change; leave the stored hash intact. - log.Trace(ctx, "BlurHash: served bytes not decodable, keeping stored hash", "artID", artID, err) return } - b := img.Bounds() - x, y := blurhash.Components(b.Dx(), b.Dy()) - hash, err := blurhash.Encode(img, x, y) - if err != nil || hash == "" { + if stored == hash && storedAt != nil && !storedAt.Before(entityVersion) { + u.remember(artID, blurHashState{sum: sum, hash: hash}) return } - u.persistAndRemember(ctx, artID, hash, sum, version) + // Clamp the persisted version up to the entity's: the hash is fresh for what is served right now, + // so the DTO accepts it after any serve, however much the read-side version over-approximates. + target := capAtNow(utils.TimeNewest(version, entityVersion)) + if err := u.persist(ctx, artID, hash, target); err != nil { + log.Warn(ctx, "BlurHash: error persisting", "artID", artID, err) + return + } + u.remember(artID, blurHashState{sum: sum, hash: hash}) } // clearIfStored clears the persisted hash after a placeholder was served (a cold map costs one row @@ -107,7 +111,7 @@ func (u *blurHashUpdater) clearIfStored(ctx context.Context, artID model.Artwork return } if !ok { - stored, err := u.loadStoredHash(ctx, artID) + stored, _, _, err := u.loadState(ctx, artID) if err != nil { return } @@ -123,12 +127,14 @@ func (u *blurHashUpdater) clearIfStored(ctx context.Context, artID model.Artwork u.remember(artID, blurHashState{}) } -func (u *blurHashUpdater) persistAndRemember(ctx context.Context, artID model.ArtworkID, hash string, sum uint64, version time.Time) { - if err := u.persist(ctx, artID, hash, version); err != nil { - log.Warn(ctx, "BlurHash: error persisting", "artID", artID, err) - return +// cachedHash returns the previously computed hash when the served bytes are unchanged. +func (u *blurHashUpdater) cachedHash(artID model.ArtworkID, sum uint64) string { + u.mutex.Lock() + defer u.mutex.Unlock() + if prev, ok := u.seen[artID]; ok && prev.hash != "" && prev.sum == sum { + return prev.hash } - u.remember(artID, blurHashState{sum: sum, version: version, hash: hash}) + return "" } func (u *blurHashUpdater) remember(artID model.ArtworkID, s blurHashState) { @@ -143,28 +149,28 @@ func checksum(data []byte) uint64 { return h.Sum64() } -func (u *blurHashUpdater) loadStoredHash(ctx context.Context, artID model.ArtworkID) (string, error) { +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.ds.Album(ctx).Get(artID.ID) if err != nil { - return "", err + return "", nil, time.Time{}, err } - return al.BlurHash, nil + return al.BlurHash, al.BlurHashUpdatedAt, al.ArtworkUpdatedAt(), nil case model.KindArtistArtwork: ar, err := u.ds.Artist(ctx).Get(artID.ID) if err != nil { - return "", err + return "", nil, time.Time{}, err } - return ar.BlurHash, nil + return ar.BlurHash, ar.BlurHashUpdatedAt, ar.ArtworkUpdatedAt(), nil case model.KindPlaylistArtwork: pl, err := u.ds.Playlist(ctx).Get(artID.ID) if err != nil { - return "", err + return "", nil, time.Time{}, err } - return pl.BlurHash, nil + return pl.BlurHash, pl.BlurHashUpdatedAt, pl.ArtworkUpdatedAt(), nil } - return "", model.ErrNotFound + return "", nil, time.Time{}, model.ErrNotFound } // isPlaceholder byte-compares against the embedded placeholder assets: placeholder artwork must never diff --git a/core/artwork/blurhash_updater_internal_test.go b/core/artwork/blurhash_updater_internal_test.go index c537bb378..6ea103d77 100644 --- a/core/artwork/blurhash_updater_internal_test.go +++ b/core/artwork/blurhash_updater_internal_test.go @@ -79,30 +79,42 @@ var _ = Describe("blurHashUpdater", func() { Expect(stored("al-1").BlurHash).To(Equal("KEEP")) }) - It("skips the write when the same bytes are served again under the same version", func() { + It("does not rewrite when the stored hash is current for the entity version", func() { id := album(model.Album{ID: "al-1", UpdatedAt: version}) data := realPNGBytes("dedup") u.update(GinkgoT().Context(), id, data, version) - // Tamper with the stored value: a second identical serve must not touch the row. - Expect(repo.UpdateBlurHash("al-1", "TAMPERED", version)).To(Succeed()) - u.update(GinkgoT().Context(), id, data, version) - Expect(stored("al-1").BlurHash).To(Equal("TAMPERED")) + first := stored("al-1") + // A later serve of the same bytes (newer tee version, unchanged entity) must not move the row. + u.update(GinkgoT().Context(), id, data, version.Add(time.Hour)) + Expect(stored("al-1").BlurHashUpdatedAt).To(HaveValue(Equal(*first.BlurHashUpdatedAt))) }) - It("re-persists the same hash when the artwork version advances", func() { - // A scan can bump the entity version without changing the cover; blur_hash_updated_at must - // follow, or the DTO's staleness gate would emit the fake hash forever after. + It("clamps the persisted version up to the entity's artwork version", func() { + // The read-side version may over-approximate (folder parents); after a serve the hash is fresh + // by construction, so the write clamps up and the DTO accepts it — omission windows close. id := album(model.Album{ID: "al-1", UpdatedAt: version}) - data := realPNGBytes("same-bytes") + data := realPNGBytes("clamp") u.update(GinkgoT().Context(), id, data, version) first := stored("al-1") + newer := version.Add(time.Hour) - u.update(GinkgoT().Context(), id, data, newer) + album(model.Album{ID: "al-1", UpdatedAt: newer, BlurHash: first.BlurHash, BlurHashUpdatedAt: first.BlurHashUpdatedAt}) + u.update(GinkgoT().Context(), id, data, version) // same bytes, old tee version second := stored("al-1") Expect(second.BlurHash).To(Equal(first.BlurHash)) Expect(second.BlurHashUpdatedAt).To(HaveValue(Equal(newer))) }) + It("restores the stored hash when it drifts from the served bytes", func() { + id := album(model.Album{ID: "al-1", UpdatedAt: version}) + data := realPNGBytes("truth") + u.update(GinkgoT().Context(), id, data, version) + truth := stored("al-1").BlurHash + Expect(repo.UpdateBlurHash("al-1", "DRIFTED", version)).To(Succeed()) + u.update(GinkgoT().Context(), id, data, version) + Expect(stored("al-1").BlurHash).To(Equal(truth)) + }) + It("does not write when a placeholder is served and nothing was ever stored", func() { id := album(model.Album{ID: "al-1", UpdatedAt: version}) u.update(GinkgoT().Context(), id, placeholderImages()[0], version) @@ -116,17 +128,13 @@ var _ = Describe("blurHashUpdater", func() { Expect(u.seen).To(BeEmpty()) }) - It("dedups across artwork ids that differ only in their embedded timestamp", func() { - // Client coverArt tokens embed a LastUpdate; the seen key must ignore it, or every scan bump - // would defeat the dedup and re-decode identical bytes. + It("keys the decode cache by identity, ignoring the artwork id's embedded timestamp", func() { id := album(model.Album{ID: "al-1", UpdatedAt: version}) data := realPNGBytes("dedup") u.update(GinkgoT().Context(), id, data, version) - Expect(repo.UpdateBlurHash("al-1", "TAMPERED", version)).To(Succeed()) bumped := id bumped.LastUpdate = version.Add(time.Hour) u.update(GinkgoT().Context(), bumped, data, version) - Expect(stored("al-1").BlurHash).To(Equal("TAMPERED")) Expect(u.seen).To(HaveLen(1)) }) }) From 9e3fc91cec8b9b7a89facac4c7cf16a7493d5fb4 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 17 Jul 2026 23:36:52 -0400 Subject: [PATCH 42/46] test(artwork): separate the cover swap in time for Windows clock granularity The quick-scan e2e swapped the cover milliseconds after the first serve; Windows' ~15ms timer granularity could collapse the stored version and the new folder timestamp into equal values, failing the staleness assertion. A 50ms gap makes the ordering deterministic on all platforms. --- core/artwork/e2e/blurhash_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/core/artwork/e2e/blurhash_test.go b/core/artwork/e2e/blurhash_test.go index f40e83820..d3820e501 100644 --- a/core/artwork/e2e/blurhash_test.go +++ b/core/artwork/e2e/blurhash_test.go @@ -152,6 +152,7 @@ var _ = Describe("BlurHash", func() { // Replace only the cover and quick-scan: the album row stays untouched while the folder's // images_updated_at advances the artwork version, so hash-keyed clients refetch. + time.Sleep(50 * time.Millisecond) // Windows clock granularity: the swap must be measurably later fakeFS.Add("Artist/Album/cover.png", realPNG("p1-swapped"), time.Now()) quickScan() From cddc30b5867ddd375f9b72fa2feb67e70cfd6413 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 17 Jul 2026 23:41:08 -0400 Subject: [PATCH 43/46] fix(persistence): gate the parent fold by the reader's album-root rule The parent folder's images_updated_at now counts only when the reader could actually serve the album-root cover: multiple album folders (disc layout), or a single folder with no images of its own. This mirrors albumRootParent's first qualification gate, so an unrelated artist-level image change no longer advances (and temporarily suppresses) the version of every sibling album with its own cover. The remaining gates (other-audio, library root) stay unmirrored: with omission plus the write-side clamp, residual over-approximation only causes a short suppression that closes on the next serve. Plan verified on a 96K-track production copy: unchanged, all PK point lookups. --- persistence/album_repository.go | 8 +++++--- persistence/album_repository_test.go | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 4217e2cbc..6b2d67964 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -226,11 +226,13 @@ func (r *albumRepository) UpdateExternalInfo(al *model.Album) error { func (r *albumRepository) selectAlbum(options ...model.QueryOptions) SelectBuilder { sql := r.newSelect(options...).Columns("album.*", "library.path as library_path", "library.name as library_name", // Folds folder image mtimes into the artwork version: an in-place cover swap moves them without - // touching the album row. Parents are included for disc-subfolder layouts, where the reader can - // serve the album-root cover. Bare column (not max()) keeps the decltype so time.Time scans. + // touching the album row. Parents count only when the reader could serve the album-root cover + // (disc subfolders, or a single folder with no images of its own), mirroring albumRootParent's + // first gate. Bare column (not max()) keeps the decltype so time.Time scans. "(select f.images_updated_at from folder f where f.id in"+ " (select je.value from json_each(album.folder_ids) je"+ - " union select p.parent_id from folder p, json_each(album.folder_ids) je2 where p.id = je2.value)"+ + " union select p.parent_id from folder p, json_each(album.folder_ids) je2 where p.id = je2.value"+ + " and (json_array_length(album.folder_ids) > 1 or json_array_length(p.image_files) = 0))"+ " order by f.images_updated_at desc limit 1) as folder_images_updated_at"). LeftJoin("library on album.library_id = library.id") sql = r.withAnnotation(sql, "album.id") diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index 05978af23..ff1ce5aa6 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -952,6 +952,25 @@ var _ = Describe("AlbumRepository folder images version", func() { Expect(al.FolderImagesUpdatedAt.Equal(rootAt)).To(BeTrue(), "the parent folder's newer cover must win") }) + It("ignores the parent when the album's single folder has images of its own", func() { + // Mirrors albumRootParent's first gate: the reader would serve the folder's own cover, so an + // unrelated artist-level image must not advance (and suppress) this album's version. + ownAt := time.Date(2030, 6, 1, 12, 0, 0, 0, time.UTC) + parentAt := ownAt.Add(time.Hour) + _, err := GetDBXBuilder().NewQuery( + "insert into folder (id, library_id, path, name, parent_id, images_updated_at, image_files) values" + + " ('fold-blur-root', 1, '.', 'Artist', '', {:parent}, '[\"artist.jpg\"]')," + + " ('fold-blur-1', 1, './Artist', 'Album', 'fold-blur-root', {:own}, '[\"cover.jpg\"]')"). + Bind(map[string]any{"parent": parentAt, "own": ownAt}).Execute() + Expect(err).ToNot(HaveOccurred()) + _, err = GetDBXBuilder().NewQuery(`update album set folder_ids = '["fold-blur-1"]' where id = '103'`).Execute() + Expect(err).ToNot(HaveOccurred()) + + al, err := repo.Get("103") + Expect(err).ToNot(HaveOccurred()) + Expect(al.FolderImagesUpdatedAt).To(HaveValue(Equal(ownAt))) + }) + It("leaves FolderImagesUpdatedAt nil when the album has no folders", func() { al, err := repo.Get("101") Expect(err).ToNot(HaveOccurred()) From 116fc5b853fe3bcd776438b95294511bfa3afaf3 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 17 Jul 2026 23:53:22 -0400 Subject: [PATCH 44/46] fix(artwork): bound decoded dimensions and stop the clamp at serve start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two hardening fixes to the inline updater. The tee's 20MB cap bounds compressed input only, so a small file declaring a huge raster could allocate GBs on decode; DecodeConfig now rejects anything over ~36M pixels from the header alone. And the write-side version clamp no longer advances past the serve's start time: a version change that lands mid-serve is not provably covered by the bytes being streamed, so the clamp stops there, the DTO omits, and the next serve of the new bytes heals — while structural read-side over-approximation (which always predates the serve) still clamps fully. --- core/artwork/artwork.go | 3 +- core/artwork/blurhash_updater.go | 23 +++++-- .../artwork/blurhash_updater_internal_test.go | 65 +++++++++++++++---- 3 files changed, 73 insertions(+), 18 deletions(-) diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go index 6fa60c816..c4d3c6b75 100644 --- a/core/artwork/artwork.go +++ b/core/artwork/artwork.go @@ -93,8 +93,9 @@ func (a *artwork) Get(ctx context.Context, artID model.ArtworkID, size int, squa // changes precisely when the served cover changes. Placeholder bytes (playlist fallback) clear. // The tee wraps r directly, so Close reaches the underlying stream (no fd leak). version := capAtNow(artReader.LastUpdated()) + start := time.Now() reader = newTeeReader(r, maxTeeBytes, - func(data []byte) { a.blurHashes.update(ctx, artID, data, version) }) + func(data []byte) { a.blurHashes.update(ctx, artID, data, version, start) }) } return reader, artReader.LastUpdated(), nil } diff --git a/core/artwork/blurhash_updater.go b/core/artwork/blurhash_updater.go index 04a03ad09..edcd0573a 100644 --- a/core/artwork/blurhash_updater.go +++ b/core/artwork/blurhash_updater.go @@ -46,9 +46,13 @@ func eligibleKind(artID model.ArtworkID) bool { return false } +// maxDecodePixels bounds the decoded raster: the tee's byte cap limits compressed size only, and a +// small file can declare huge dimensions that would allocate GBs on decode (decompression bomb). +const maxDecodePixels = 36_000_000 // ~6000x6000; decoded RGBA tops out around 144MB + // update hashes the exact bytes served for artID and persists the result. Placeholder bytes mean the -// entity has no artwork anymore, so they clear a stored hash instead. -func (u *blurHashUpdater) update(ctx context.Context, artID model.ArtworkID, data []byte, version time.Time) { +// entity has no artwork anymore, so they clear a stored hash instead. start is when the serve began. +func (u *blurHashUpdater) update(ctx context.Context, artID model.ArtworkID, data []byte, version, start time.Time) { // Decoding arbitrary image bytes can panic; the serve already succeeded, so just log it. defer func() { if r := recover(); r != nil { @@ -66,9 +70,14 @@ func (u *blurHashUpdater) update(ctx context.Context, artID model.ArtworkID, dat sum := checksum(data) hash := u.cachedHash(artID, sum) if hash == "" { + cfg, _, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil || cfg.Width*cfg.Height > maxDecodePixels { + // Undecodable or oversized served bytes are not proof of change; keep the stored hash. + log.Trace(ctx, "BlurHash: skipping served bytes", "artID", artID, "width", cfg.Width, "height", cfg.Height, err) + return + } img, _, err := image.Decode(bytes.NewReader(data)) if err != nil { - // Undecodable served bytes are not proof of change; leave the stored hash intact. log.Trace(ctx, "BlurHash: served bytes not decodable, keeping stored hash", "artID", artID, err) return } @@ -82,12 +91,16 @@ func (u *blurHashUpdater) update(ctx context.Context, artID model.ArtworkID, dat if err != nil { return } + // Clamp the persisted version up to the entity's, but never past the serve's start: a version that + // predates the serve is provably covered by the served bytes, one that landed mid-serve is not — + // there the clamp stops, the DTO omits, and the next serve of the new bytes heals. + if entityVersion.After(start) { + entityVersion = start + } if stored == hash && storedAt != nil && !storedAt.Before(entityVersion) { u.remember(artID, blurHashState{sum: sum, hash: hash}) return } - // Clamp the persisted version up to the entity's: the hash is fresh for what is served right now, - // so the DTO accepts it after any serve, however much the read-side version over-approximates. target := capAtNow(utils.TimeNewest(version, entityVersion)) if err := u.persist(ctx, artID, hash, target); err != nil { log.Warn(ctx, "BlurHash: error persisting", "artID", artID, err) diff --git a/core/artwork/blurhash_updater_internal_test.go b/core/artwork/blurhash_updater_internal_test.go index 6ea103d77..4fe076ea9 100644 --- a/core/artwork/blurhash_updater_internal_test.go +++ b/core/artwork/blurhash_updater_internal_test.go @@ -2,6 +2,8 @@ package artwork import ( "bytes" + "encoding/binary" + "hash/crc32" "image" "image/color" "image/png" @@ -13,6 +15,25 @@ import ( . "github.com/onsi/gomega" ) +// hugePNGHeader builds a valid PNG signature+IHDR declaring a 50000x50000 raster with no pixel data: +// enough for DecodeConfig to report the dimensions the decode gate must reject. +func hugePNGHeader() []byte { + var buf bytes.Buffer + buf.Write([]byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a}) + ihdr := make([]byte, 13) + binary.BigEndian.PutUint32(ihdr[0:], 50000) + binary.BigEndian.PutUint32(ihdr[4:], 50000) + ihdr[8] = 8 // bit depth + ihdr[9] = 6 // RGBA + var chunk bytes.Buffer + chunk.WriteString("IHDR") + chunk.Write(ihdr) + _ = binary.Write(&buf, binary.BigEndian, uint32(13)) + buf.Write(chunk.Bytes()) + _ = binary.Write(&buf, binary.BigEndian, crc32.ChecksumIEEE(chunk.Bytes())) + return buf.Bytes() +} + // pngImage builds a deterministic 2x2 PNG image for a label (color derived from label bytes). func pngImage(label string) *image.RGBA { img := image.NewRGBA(image.Rect(0, 0, 2, 2)) @@ -61,7 +82,7 @@ var _ = Describe("blurHashUpdater", func() { It("persists a hash computed from the served bytes", func() { id := album(model.Album{ID: "al-1", UpdatedAt: version}) - u.update(GinkgoT().Context(), id, realPNGBytes("x"), version) + u.update(GinkgoT().Context(), id, realPNGBytes("x"), version, time.Now()) al := stored("al-1") Expect(al.BlurHash).ToNot(BeEmpty()) Expect(al.BlurHashUpdatedAt).To(HaveValue(Equal(version))) @@ -69,23 +90,23 @@ var _ = Describe("blurHashUpdater", func() { It("clears the stored hash when the served bytes are a placeholder", func() { id := album(model.Album{ID: "al-1", UpdatedAt: version, BlurHash: "OLD"}) - u.update(GinkgoT().Context(), id, placeholderImages()[0], version) + u.update(GinkgoT().Context(), id, placeholderImages()[0], version, time.Now()) Expect(stored("al-1").BlurHash).To(BeEmpty()) }) It("leaves the hash untouched on undecodable bytes", func() { id := album(model.Album{ID: "al-1", UpdatedAt: version, BlurHash: "KEEP"}) - u.update(GinkgoT().Context(), id, []byte("not an image"), version) + u.update(GinkgoT().Context(), id, []byte("not an image"), version, time.Now()) Expect(stored("al-1").BlurHash).To(Equal("KEEP")) }) It("does not rewrite when the stored hash is current for the entity version", func() { id := album(model.Album{ID: "al-1", UpdatedAt: version}) data := realPNGBytes("dedup") - u.update(GinkgoT().Context(), id, data, version) + u.update(GinkgoT().Context(), id, data, version, time.Now()) first := stored("al-1") // A later serve of the same bytes (newer tee version, unchanged entity) must not move the row. - u.update(GinkgoT().Context(), id, data, version.Add(time.Hour)) + u.update(GinkgoT().Context(), id, data, version.Add(time.Hour), time.Now()) Expect(stored("al-1").BlurHashUpdatedAt).To(HaveValue(Equal(*first.BlurHashUpdatedAt))) }) @@ -94,12 +115,12 @@ var _ = Describe("blurHashUpdater", func() { // by construction, so the write clamps up and the DTO accepts it — omission windows close. id := album(model.Album{ID: "al-1", UpdatedAt: version}) data := realPNGBytes("clamp") - u.update(GinkgoT().Context(), id, data, version) + u.update(GinkgoT().Context(), id, data, version, time.Now()) first := stored("al-1") newer := version.Add(time.Hour) album(model.Album{ID: "al-1", UpdatedAt: newer, BlurHash: first.BlurHash, BlurHashUpdatedAt: first.BlurHashUpdatedAt}) - u.update(GinkgoT().Context(), id, data, version) // same bytes, old tee version + u.update(GinkgoT().Context(), id, data, version, time.Now()) // same bytes, old tee version second := stored("al-1") Expect(second.BlurHash).To(Equal(first.BlurHash)) Expect(second.BlurHashUpdatedAt).To(HaveValue(Equal(newer))) @@ -108,16 +129,36 @@ var _ = Describe("blurHashUpdater", func() { It("restores the stored hash when it drifts from the served bytes", func() { id := album(model.Album{ID: "al-1", UpdatedAt: version}) data := realPNGBytes("truth") - u.update(GinkgoT().Context(), id, data, version) + u.update(GinkgoT().Context(), id, data, version, time.Now()) truth := stored("al-1").BlurHash Expect(repo.UpdateBlurHash("al-1", "DRIFTED", version)).To(Succeed()) - u.update(GinkgoT().Context(), id, data, version) + u.update(GinkgoT().Context(), id, data, version, time.Now()) Expect(stored("al-1").BlurHash).To(Equal(truth)) }) + It("skips images whose declared dimensions exceed the decode bound", func() { + // The tee's byte cap limits compressed size only; a decompression bomb must be rejected from + // the header before the raster is allocated. + id := album(model.Album{ID: "al-1", UpdatedAt: version, BlurHash: "KEEP"}) + u.update(GinkgoT().Context(), id, hugePNGHeader(), version, time.Now()) + Expect(stored("al-1").BlurHash).To(Equal("KEEP")) + }) + + It("does not mark older served bytes as current when the version advances mid-serve", func() { + // The cover was replaced and scanned after this serve started: the clamp must stop at the + // serve's start, so the DTO keeps omitting until the new bytes are served. + changedAt := version.Add(time.Hour) + id := album(model.Album{ID: "al-1", UpdatedAt: changedAt}) + u.update(GinkgoT().Context(), id, realPNGBytes("old-bytes"), version, version) + al := stored("al-1") + Expect(al.BlurHash).ToNot(BeEmpty()) + Expect(al.BlurHashUpdatedAt).To(HaveValue(Equal(version))) + Expect(al.BlurHashUpdatedAt.Before(al.ArtworkUpdatedAt())).To(BeTrue(), "must read as stale") + }) + It("does not write when a placeholder is served and nothing was ever stored", func() { id := album(model.Album{ID: "al-1", UpdatedAt: version}) - u.update(GinkgoT().Context(), id, placeholderImages()[0], version) + u.update(GinkgoT().Context(), id, placeholderImages()[0], version, time.Now()) Expect(stored("al-1").BlurHashUpdatedAt).To(BeNil()) }) @@ -131,10 +172,10 @@ var _ = Describe("blurHashUpdater", func() { It("keys the decode cache by identity, ignoring the artwork id's embedded timestamp", func() { id := album(model.Album{ID: "al-1", UpdatedAt: version}) data := realPNGBytes("dedup") - u.update(GinkgoT().Context(), id, data, version) + u.update(GinkgoT().Context(), id, data, version, time.Now()) bumped := id bumped.LastUpdate = version.Add(time.Hour) - u.update(GinkgoT().Context(), bumped, data, version) + u.update(GinkgoT().Context(), bumped, data, version, time.Now()) Expect(u.seen).To(HaveLen(1)) }) }) From ebaf804dbf5f4556371a0314667f959dea741f04 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 18 Jul 2026 00:05:57 -0400 Subject: [PATCH 45/46] fix(persistence): mirror the reader's remaining cheap album-root gates The parent fold now also requires a single common parent across the album's folders (a compilation spread over artist folders has no album root), excludes a parent that is itself one of the album's folders, and excludes the library root, matching albumRootParent. The subtree-audio gate stays unmirrored on purpose: it would need a per-row LIKE prefix scan over the folder table, and with omission plus the write-side clamp its absence can only suppress a hash briefly until the next serve. Plan verified on a 96K-track production copy: all folder accesses remain PK point lookups. --- persistence/album_repository.go | 16 +++++++++++----- persistence/album_repository_test.go | 25 +++++++++++++++++++++++-- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 6b2d67964..96129fb8b 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -226,13 +226,19 @@ func (r *albumRepository) UpdateExternalInfo(al *model.Album) error { func (r *albumRepository) selectAlbum(options ...model.QueryOptions) SelectBuilder { sql := r.newSelect(options...).Columns("album.*", "library.path as library_path", "library.name as library_name", // Folds folder image mtimes into the artwork version: an in-place cover swap moves them without - // touching the album row. Parents count only when the reader could serve the album-root cover - // (disc subfolders, or a single folder with no images of its own), mirroring albumRootParent's - // first gate. Bare column (not max()) keeps the decltype so time.Time scans. + // touching the album row. The parent counts only when albumRootParent could serve it: single + // common parent, not the library root, not an album folder, and disc subfolders or an imageless + // folder. The subtree-audio gate is deliberately unmirrored (a per-row LIKE scan): it can only + // suppress a hash briefly, healed on the next serve. Bare column keeps the datetime decltype. "(select f.images_updated_at from folder f where f.id in"+ " (select je.value from json_each(album.folder_ids) je"+ - " union select p.parent_id from folder p, json_each(album.folder_ids) je2 where p.id = je2.value"+ - " and (json_array_length(album.folder_ids) > 1 or json_array_length(p.image_files) = 0))"+ + " union select pf.id from json_each(album.folder_ids) je2"+ + " join folder p on p.id = je2.value join folder pf on pf.id = p.parent_id"+ + " where pf.parent_id <> ''"+ + " and (json_array_length(album.folder_ids) > 1 or json_array_length(p.image_files) = 0)"+ + " and pf.id not in (select je3.value from json_each(album.folder_ids) je3)"+ + " and (select count(distinct p2.parent_id) from folder p2, json_each(album.folder_ids) je4"+ + " where p2.id = je4.value) = 1)"+ " order by f.images_updated_at desc limit 1) as folder_images_updated_at"). LeftJoin("library on album.library_id = library.id") sql = r.withAnnotation(sql, "album.id") diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index ff1ce5aa6..e8efab5b5 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -910,7 +910,7 @@ var _ = Describe("AlbumRepository folder images version", func() { Expect(GetDBXBuilder().NewQuery("select folder_ids from album where id = '103'"). Row(&origFolderIDs)).To(Succeed()) DeferCleanup(func() { - _, err := GetDBXBuilder().NewQuery("delete from folder where id in ('fold-blur-1', 'fold-blur-root')").Execute() + _, err := GetDBXBuilder().NewQuery("delete from folder where id like 'fold-blur-%'").Execute() Expect(err).ToNot(HaveOccurred()) _, err = GetDBXBuilder().NewQuery("update album set folder_ids = {:f} where id = '103'"). Bind(map[string]any{"f": origFolderIDs}).Execute() @@ -938,9 +938,11 @@ var _ = Describe("AlbumRepository folder images version", func() { It("includes the parent folder's images (album-root cover with disc subfolders)", func() { discAt := time.Date(2030, 6, 1, 12, 0, 0, 0, time.UTC) rootAt := discAt.Add(time.Hour) // the root cover is the newest image + // The album root sits under an artist folder (non-empty parent_id): the library root never counts. _, err := GetDBXBuilder().NewQuery( "insert into folder (id, library_id, path, name, parent_id, images_updated_at) values" + - " ('fold-blur-root', 1, '.', 'Album', '', {:root}), ('fold-blur-1', 1, './Album', 'CD1', 'fold-blur-root', {:disc})"). + " ('fold-blur-root', 1, './Artist', 'Album', 'fold-blur-artist', {:root})," + + " ('fold-blur-1', 1, './Artist/Album', 'CD1', 'fold-blur-root', {:disc})"). Bind(map[string]any{"root": rootAt, "disc": discAt}).Execute() Expect(err).ToNot(HaveOccurred()) _, err = GetDBXBuilder().NewQuery(`update album set folder_ids = '["fold-blur-1"]' where id = '103'`).Execute() @@ -952,6 +954,25 @@ var _ = Describe("AlbumRepository folder images version", func() { Expect(al.FolderImagesUpdatedAt.Equal(rootAt)).To(BeTrue(), "the parent folder's newer cover must win") }) + It("ignores parents when the album's folders do not share a single one (mixed parents)", func() { + // A compilation spread across artist folders has no album root; folding every artist's images + // would suppress the album's hash on any unrelated artist-image change. + at := time.Date(2030, 6, 1, 12, 0, 0, 0, time.UTC) + _, err := GetDBXBuilder().NewQuery( + "insert into folder (id, library_id, path, name, parent_id, images_updated_at) values" + + " ('fold-blur-root', 1, '.', 'ArtistA', 'fold-blur-lib', {:parent})," + + " ('fold-blur-1', 1, './A', 'Songs', 'fold-blur-root', {:own})," + + " ('fold-blur-2', 1, './B', 'Songs', 'fold-blur-other', {:own})"). + Bind(map[string]any{"parent": at.Add(time.Hour), "own": at}).Execute() + Expect(err).ToNot(HaveOccurred()) + _, err = GetDBXBuilder().NewQuery(`update album set folder_ids = '["fold-blur-1","fold-blur-2"]' where id = '103'`).Execute() + Expect(err).ToNot(HaveOccurred()) + + al, err := repo.Get("103") + Expect(err).ToNot(HaveOccurred()) + Expect(al.FolderImagesUpdatedAt).To(HaveValue(Equal(at)), "only the albums' own folders must count") + }) + It("ignores the parent when the album's single folder has images of its own", func() { // Mirrors albumRootParent's first gate: the reader would serve the folder's own cover, so an // unrelated artist-level image must not advance (and suppress) this album's version. From 14cbff5edcfc9226eadde02c0139f94c8784c445 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 18 Jul 2026 00:12:22 -0400 Subject: [PATCH 46/46] fix(artwork): prevent 32-bit overflow in the decode pixel guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cfg.Width*cfg.Height in int overflows on 32-bit builds (armv5/v6/v7, 386) for dimensions like 50000x50000, going negative and bypassing maxDecodePixels — the exact bomb the guard exists to reject. Multiply in int64. --- core/artwork/blurhash_updater.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/artwork/blurhash_updater.go b/core/artwork/blurhash_updater.go index edcd0573a..e76430c76 100644 --- a/core/artwork/blurhash_updater.go +++ b/core/artwork/blurhash_updater.go @@ -71,7 +71,8 @@ func (u *blurHashUpdater) update(ctx context.Context, artID model.ArtworkID, dat hash := u.cachedHash(artID, sum) if hash == "" { cfg, _, err := image.DecodeConfig(bytes.NewReader(data)) - if err != nil || cfg.Width*cfg.Height > maxDecodePixels { + // int64: on 32-bit builds the pixel product can overflow int and bypass the guard. + if err != nil || int64(cfg.Width)*int64(cfg.Height) > maxDecodePixels { // Undecodable or oversized served bytes are not proof of change; keep the stored hash. log.Trace(ctx, "BlurHash: skipping served bytes", "artID", artID, "width", cfg.Width, "height", cfg.Height, err) return