mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 10:27:08 +02:00
Compare commits
47 commits
master
...
feat/artwo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14cbff5edc | ||
|
|
ebaf804dbf | ||
|
|
116fc5b853 | ||
|
|
cddc30b586 | ||
|
|
9e3fc91cec | ||
|
|
a98bebc314 | ||
|
|
817baa5c1a | ||
|
|
f0fe070ba1 | ||
|
|
08d83a7785 | ||
|
|
9a36047096 | ||
|
|
07af29fbb4 | ||
|
|
2499de2bac | ||
|
|
83e9bb8180 | ||
|
|
7307fd716b | ||
|
|
b63c9b095b | ||
|
|
e8fac4c335 | ||
|
|
f6dd722119 | ||
|
|
2169938b30 | ||
|
|
eb5ecabc5a | ||
|
|
bf7ae5e82e | ||
|
|
5cd75503cb | ||
|
|
2cd967a456 | ||
|
|
66d6fdc1a6 | ||
|
|
3759488db5 | ||
|
|
ae36e4dfc7 | ||
|
|
d0ac427377 | ||
|
|
7a61776c1c | ||
|
|
a7eec3afc3 | ||
|
|
0e74cf0ab1 | ||
|
|
9ac2c6a5e3 | ||
|
|
c4ca3dca5e | ||
|
|
3a8505584b | ||
|
|
f9f6d36ebb | ||
|
|
43500c0ffe | ||
|
|
b040345fb0 | ||
|
|
5bdeaad95e | ||
|
|
696399dab7 | ||
|
|
9ea4e22ea0 | ||
|
|
4d8b486dfd | ||
|
|
e100c48102 | ||
|
|
0b365a0090 | ||
|
|
76e1fba067 | ||
|
|
585ac0aab3 | ||
|
|
9f9019df07 | ||
|
|
f763ebff5b | ||
|
|
a115726e71 | ||
|
|
470b4bdfa0 |
36 changed files with 1567 additions and 67 deletions
3
.github/workflows/pipeline.yml
vendored
3
.github/workflows/pipeline.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
177
core/artwork/blurhash/blurhash.go
Normal file
177
core/artwork/blurhash/blurhash.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
41
core/artwork/blurhash/blurhash_bench_test.go
Normal file
41
core/artwork/blurhash/blurhash_bench_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
17
core/artwork/blurhash/blurhash_suite_test.go
Normal file
17
core/artwork/blurhash/blurhash_suite_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
113
core/artwork/blurhash/blurhash_test.go
Normal file
113
core/artwork/blurhash/blurhash_test.go
Normal file
|
|
@ -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]))
|
||||
})
|
||||
})
|
||||
224
core/artwork/blurhash_updater.go
Normal file
224
core/artwork/blurhash_updater.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
181
core/artwork/blurhash_updater_internal_test.go
Normal file
181
core/artwork/blurhash_updater_internal_test.go
Normal file
|
|
@ -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))
|
||||
})
|
||||
})
|
||||
207
core/artwork/e2e/blurhash_test.go
Normal file
207
core/artwork/e2e/blurhash_test.go
Normal file
|
|
@ -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())
|
||||
})
|
||||
})
|
||||
|
|
@ -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{}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
48
core/artwork/tee_reader.go
Normal file
48
core/artwork/tee_reader.go
Normal file
|
|
@ -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
|
||||
}
|
||||
59
core/artwork/tee_reader_internal_test.go
Normal file
59
core/artwork/tee_reader_internal_test.go
Normal file
|
|
@ -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())
|
||||
})
|
||||
})
|
||||
16
db/migrations/20260716030719_add_artwork_blur_hash.sql
Normal file
16
db/migrations/20260716030719_add_artwork_blur_hash.sql
Normal file
|
|
@ -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;
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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!")
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue