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 diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go index b8c395c12..c4d3c6b75 100644 --- a/core/artwork/artwork.go +++ b/core/artwork/artwork.go @@ -19,20 +19,34 @@ 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) } 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} + return &artwork{ds: ds, cache: cache, ffmpeg: ffmpeg, provider: provider, blurHashes: newBlurHashUpdater(ds)} } 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 { @@ -47,6 +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) { + // 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 { @@ -70,7 +87,17 @@ func (a *artwork) Get(ctx context.Context, artID model.ArtworkID, size int, squa } return nil, time.Time{}, err } - return r, artReader.LastUpdated(), nil + reader = r + 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). + version := capAtNow(artReader.LastUpdated()) + start := time.Now() + reader = newTeeReader(r, maxTeeBytes, + func(data []byte) { a.blurHashes.update(ctx, artID, data, version, start) }) + } + return reader, artReader.LastUpdated(), nil } type coverArtGetter interface { diff --git a/core/artwork/blurhash/blurhash.go b/core/artwork/blurhash/blurhash.go new file mode 100644 index 000000000..302ec83c0 --- /dev/null +++ b/core/artwork/blurhash/blurhash.go @@ -0,0 +1,177 @@ +// 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" + "sync" + + 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") + } + rgba := toRGBA(downscale(img)) + bounds := rgba.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)) + } + } + + 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++ { + 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] + 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 +} + +// 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() + 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) +} + +// 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] + value /= 83 + } + return string(b) +} 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/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])) + }) +}) diff --git a/core/artwork/blurhash_updater.go b/core/artwork/blurhash_updater.go new file mode 100644 index 000000000..e76430c76 --- /dev/null +++ b/core/artwork/blurhash_updater.go @@ -0,0 +1,224 @@ +package artwork + +import ( + "bytes" + "context" + "fmt" + "hash/fnv" + "image" + "io" + "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/resources" + "github.com/navidrome/navidrome/utils" +) + +// 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 + hash string +} + +// 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 { + ds model.DataStore + mutex sync.Mutex + seen map[model.ArtworkID]blurHashState +} + +func newBlurHashUpdater(ds model.DataStore) *blurHashUpdater { + return &blurHashUpdater{ds: ds, seen: make(map[model.ArtworkID]blurHashState)} +} + +func eligibleKind(artID model.ArtworkID) bool { + switch artID.Kind { + case model.KindAlbumArtwork, model.KindArtistArtwork, model.KindPlaylistArtwork: + return true + } + 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. 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 { + log.Error(ctx, "BlurHash: recovered from panic", "artID", artID, "panic", r) + } + }() + // 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) + if isPlaceholder(data) { + u.clearIfStored(ctx, artID) + return + } + sum := checksum(data) + hash := u.cachedHash(artID, sum) + if hash == "" { + cfg, _, err := image.DecodeConfig(bytes.NewReader(data)) + // 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 + } + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + 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 + } + } + stored, storedAt, entityVersion, err := u.loadState(ctx, artID) + 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 + } + 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 +// 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) { + if !eligibleKind(artID) { + return + } + artID.LastUpdate = time.Time{} + 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.loadState(ctx, artID) + if err != nil { + return + } + if stored == "" { + u.remember(artID, blurHashState{}) + return + } + } + 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{}) +} + +// 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 + } + return "" +} + +func (u *blurHashUpdater) remember(artID model.ArtworkID, s blurHashState) { + u.mutex.Lock() + u.seen[artID] = s + u.mutex.Unlock() +} + +func checksum(data []byte) uint64 { + h := fnv.New64a() + _, _ = h.Write(data) + return h.Sum64() +} + +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 "", nil, time.Time{}, err + } + return al.BlurHash, al.BlurHashUpdatedAt, al.ArtworkUpdatedAt(), nil + case model.KindArtistArtwork: + ar, err := u.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.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 +} + +// 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) { + 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: + return u.ds.Album(ctx).UpdateBlurHash(artID.ID, hash, version) + case model.KindArtistArtwork: + return u.ds.Artist(ctx).UpdateBlurHash(artID.ID, hash, version) + case model.KindPlaylistArtwork: + 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 new file mode 100644 index 000000000..4fe076ea9 --- /dev/null +++ b/core/artwork/blurhash_updater_internal_test.go @@ -0,0 +1,181 @@ +package artwork + +import ( + "bytes" + "encoding/binary" + "hash/crc32" + "image" + "image/color" + "image/png" + "time" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "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)) + 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 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 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 = newBlurHashUpdater(ds) + version = time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + }) + + 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, time.Now()) + al := stored("al-1") + Expect(al.BlurHash).ToNot(BeEmpty()) + Expect(al.BlurHashUpdatedAt).To(HaveValue(Equal(version))) + }) + + 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, 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, 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, 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), time.Now()) + Expect(stored("al-1").BlurHashUpdatedAt).To(HaveValue(Equal(*first.BlurHashUpdatedAt))) + }) + + 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("clamp") + 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, 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))) + }) + + 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, time.Now()) + truth := stored("al-1").BlurHash + Expect(repo.UpdateBlurHash("al-1", "DRIFTED", version)).To(Succeed()) + 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, time.Now()) + Expect(stored("al-1").BlurHashUpdatedAt).To(BeNil()) + }) + + It("ignores non-eligible artwork kinds", func() { + Expect(func() { + u.clearIfStored(GinkgoT().Context(), model.ArtworkID{Kind: model.KindMediaFileArtwork, ID: "mf-1"}) + }).ToNot(Panic()) + Expect(u.seen).To(BeEmpty()) + }) + + 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, time.Now()) + bumped := id + bumped.LastUpdate = version.Add(time.Hour) + u.update(GinkgoT().Context(), bumped, data, version, time.Now()) + Expect(u.seen).To(HaveLen(1)) + }) +}) diff --git a/core/artwork/e2e/blurhash_test.go b/core/artwork/e2e/blurhash_test.go new file mode 100644 index 000000000..d3820e501 --- /dev/null +++ b/core/artwork/e2e/blurhash_test.go @@ -0,0 +1,207 @@ +package artworke2e_test + +import ( + "os" + "path/filepath" + "testing/fstest" + "time" + + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("BlurHash", func() { + BeforeEach(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"), + "Artist/Album/cover.png": realPNG("blurhash-album"), + }) + scan() + al := firstAlbum() + Expect(al.BlurHash).To(BeEmpty()) + + readArtwork(al.CoverArtID()) + + 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() { + 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()) + + 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()) + // 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() { + setLayout(fstest.MapFS{ + "Artist/Album/01 - Song.mp3": trackFile(1, "Song"), + "Artist/Album/cover.png": realPNG("original-cover"), + }) + scan() + al := firstAlbum() + readArtwork(al.CoverArtID()) + 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. + setLayout(fstest.MapFS{ + "Artist/Album/01 - Song.mp3": trackFile(1, "Song"), + "Artist/Album/cover.png": realPNG("swapped-cover"), + }) + scan() + readArtwork(al.CoverArtID()) + + 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() { + setLayout(fstest.MapFS{ + "Artist/Album/01 - Song.mp3": trackFile(1, "Song"), + "Artist/Album/cover.png": realPNG("vanishing-cover"), + }) + scan() + al := firstAlbum() + readArtwork(al.CoverArtID()) + 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), which clears the + // stored hash inline. + setLayout(fstest.MapFS{ + "Artist/Album/01 - Song.mp3": trackFile(1, "Song"), + }) + Expect(readOrPlaceholder(al.CoverArtID())).To(Equal(placeholderBytes())) + + Expect(storedAlbum(al.ID).BlurHash).To(BeEmpty()) + }) + + 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 + setLayout(fstest.MapFS{ + "Artist/Album/01 - Song.mp3": trackFile(1, "Song"), + "Artist/Album/cover.png": cover, + }) + scan() + al := firstAlbum() + readArtwork(al.CoverArtID()) + 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") + swapped.ModTime = fixed + setLayout(fstest.MapFS{ + "Artist/Album/01 - Song.mp3": trackFile(1, "Song"), + "Artist/Album/cover.png": swapped, + }) + readArtwork(al.CoverArtID()) + + 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. + 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() + + 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. + 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()) + 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())) + + 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() { + 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()) + + Expect(storedAlbum(al.ID).BlurHash).To(BeEmpty()) + }) +}) 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{} 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/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 diff --git a/core/artwork/tee_reader.go b/core/artwork/tee_reader.go new file mode 100644 index 000000000..958f43b59 --- /dev/null +++ b/core/artwork/tee_reader.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 the callback only ever receives a complete, bounded payload. +type teeReader struct { + src io.ReadCloser + buf bytes.Buffer + maxBytes int + onComplete func(data []byte) + eof bool + over 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.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/core/artwork/tee_reader_internal_test.go b/core/artwork/tee_reader_internal_test.go new file mode 100644 index 000000000..64cfecf19 --- /dev/null +++ b/core/artwork/tee_reader_internal_test.go @@ -0,0 +1,59 @@ +package artwork + +import ( + "bytes" + "io" + + . "github.com/onsi/ginkgo/v2" + . "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"))) + 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()) + }) +}) 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..40c0c4014 --- /dev/null +++ b/db/migrations/20260716030719_add_artwork_blur_hash.sql @@ -0,0 +1,16 @@ +-- +goose Up +-- 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 not null default ''; +alter table artist add column blur_hash_updated_at datetime; +alter table playlist add column blur_hash varchar not null default ''; +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..16a093393 100644 --- a/model/album.go +++ b/model/album.go @@ -67,12 +67,35 @@ 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 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"` + + // 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 { return artworkIDFromAlbum(a) } +// 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 hashing the served bytes instead. +func (a Album) ArtworkUpdatedAt() time.Time { + t := a.UpdatedAt + if a.ImportedAt.After(t) { + t = a.ImportedAt + } + if a.FolderImagesUpdatedAt != nil && a.FolderImagesUpdatedAt.After(t) { + t = *a.FolderImagesUpdatedAt + } + 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]) @@ -139,6 +162,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/album_test.go b/model/album_test.go index 0f4c912cd..d9f7ff624 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,30 @@ 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("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(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/model/artist.go b/model/artist.go index f9c4bffd5..baaf1e4b1 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:"-" json:"blurHash,omitempty" hash:"ignore"` + BlurHashUpdatedAt *time.Time `structs:"-" json:"-" hash:"ignore"` } type ArtistStats struct { @@ -63,6 +66,16 @@ func (a Artist) CoverArtID() ArtworkID { return artworkIDFromArtist(a) } +// 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 hashing the served bytes instead. +func (a Artist) ArtworkUpdatedAt() time.Time { + if a.UpdatedAt == nil { + return time.Time{} + } + return *a.UpdatedAt +} + func (a Artist) UploadedImagePath() string { return UploadedImagePath(consts.EntityArtist, a.UploadedImage) } @@ -87,6 +100,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/artist_test.go b/model/artist_test.go index db897d3d5..2d78d8ab3 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", func() { + Expect(model.Artist{UpdatedAt: &later, ExternalInfoUpdatedAt: &base}.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)) + }) +}) diff --git a/model/playlist.go b/model/playlist.go index 185f6f942..bb12d7fe4 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -31,6 +31,9 @@ type Playlist struct { CreatedAt time.Time `structs:"created_at" json:"createdAt"` UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` + BlurHash string `structs:"-" json:"blurHash,omitempty" hash:"ignore"` + BlurHashUpdatedAt *time.Time `structs:"-" json:"-" hash:"ignore"` + // SmartPlaylist attributes Rules *criteria.Criteria `structs:"rules" json:"rules"` EvaluatedAt *time.Time `structs:"evaluated_at" json:"evaluatedAt"` @@ -40,6 +43,10 @@ func (pls Playlist) IsSmartPlaylist() bool { return pls.Rules != nil && pls.Rules.Expression != nil } +func (pls Playlist) ArtworkUpdatedAt() time.Time { + return pls.UpdatedAt +} + // RefreshDelay returns the playlist's own refresh window when set, falling // back to the global SmartPlaylistRefreshDelay. func (pls Playlist) RefreshDelay() time.Duration { @@ -145,6 +152,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/model/playlist_test.go b/model/playlist_test.go index d936129ce..48b050f22 100644 --- a/model/playlist_test.go +++ b/model/playlist_test.go @@ -74,3 +74,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)) + }) +}) diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 6ebbd9202..96129fb8b 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -213,6 +213,10 @@ func (r *albumRepository) Put(al *model.Album) error { return nil } +func (r *albumRepository) UpdateBlurHash(id, blurHash string, artworkUpdatedAt time.Time) error { + return r.updateBlurHash(id, blurHash, artworkUpdatedAt) +} + // 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") @@ -220,7 +224,22 @@ 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. 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 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") return r.applyLibraryFilter(sql) diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index 64ff0095e..e8efab5b5 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -899,3 +899,141 @@ 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 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() + 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("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, './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() + 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("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. + 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()) + Expect(al.FolderImagesUpdatedAt).To(BeNil()) + }) +}) + +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)) + + // 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")) + }) +}) diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index b542dedb4..1308a1d5c 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -232,6 +232,10 @@ func (r *artistRepository) Put(a *model.Artist, colsToUpdate ...string) error { return err } +func (r *artistRepository) UpdateBlurHash(id, blurHash string, artworkUpdatedAt time.Time) error { + return r.updateBlurHash(id, blurHash, artworkUpdatedAt) +} + 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..e5e97043c 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -155,6 +155,10 @@ func (r *playlistRepository) GetWithTracks(id string, refreshSmartPlaylist, incl return pls, nil } +func (r *playlistRepository) UpdateBlurHash(id, blurHash string, artworkUpdatedAt time.Time) error { + return r.updateBlurHash(id, blurHash, artworkUpdatedAt) +} + func (r *playlistRepository) FindByPath(path string) (*model.Playlist, error) { return r.findBy(Eq{"path": path}) } 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 { 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 } } } diff --git a/server/jellyfin/dto/blurhash.go b/server/jellyfin/dto/blurhash.go index aaf6ff2af..8952ab3d9 100644 --- a/server/jellyfin/dto/blurhash.go +++ b/server/jellyfin/dto/blurhash.go @@ -1,36 +1,23 @@ package dto -import "hash/fnv" +import ( + "time" +) -// 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] +// 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 string(b) + return "" } -func pow83(n int) int { - result := 1 - for range n { - result *= 83 +// 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 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. -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" + base83(dc, 4) + 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 a6e36131d..a661800f9 100644 --- a/server/jellyfin/dto/blurhash_test.go +++ b/server/jellyfin/dto/blurhash_test.go @@ -1,27 +1,41 @@ package dto import ( - "strings" + "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) -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) - } +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, version)).To(Equal("LEHV6nWB2yk8")) }) - It("is deterministic for the same seed", func() { - Expect(blurHash("cover-tag-1")).To(Equal(blurHash("cover-tag-1"))) + 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, version)).To(Equal("LEHV6nWB2yk8")) }) - It("differs for different seeds", func() { - Expect(blurHash("cover-tag-1")).ToNot(Equal(blurHash("cover-tag-2"))) + It("omits when there is no stored hash", func() { + Expect(primaryBlurHash("", nil, version)).To(BeEmpty()) + }) + + It("omits when the stored hash is stale (cover changed, not yet re-served)", func() { + stale := version.Add(-time.Hour) + 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 c65b8933d..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: blurHash(al.ID)}}, + 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: blurHash(ar.ID)}}, + 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: blurHash(tag)}}, + 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 e72efd617..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() { @@ -390,3 +390,31 @@ 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 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 + 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() { + 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")) + }) +}) 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()) }) }) 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]