mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-31 15:37:51 +01: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:
|
env:
|
||||||
FFMPEG_VERSION: "7.1"
|
FFMPEG_VERSION: "7.1"
|
||||||
FFMPEG_REPOSITORY: navidrome/ffmpeg-windows-builds
|
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:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,20 +19,34 @@ import (
|
||||||
|
|
||||||
var ErrUnavailable = errors.New("artwork unavailable")
|
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 {
|
type Artwork interface {
|
||||||
Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (io.ReadCloser, time.Time, error)
|
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)
|
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 {
|
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 {
|
type artwork struct {
|
||||||
ds model.DataStore
|
ds model.DataStore
|
||||||
cache cache.FileCache
|
cache cache.FileCache
|
||||||
ffmpeg ffmpeg.FFmpeg
|
ffmpeg ffmpeg.FFmpeg
|
||||||
provider external.Provider
|
provider external.Provider
|
||||||
|
blurHashes *blurHashUpdater
|
||||||
}
|
}
|
||||||
|
|
||||||
type artworkReader interface {
|
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)
|
reader, lastUpdate, err = a.Get(ctx, artID, size, square)
|
||||||
}
|
}
|
||||||
if errors.Is(err, ErrUnavailable) {
|
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 {
|
if artID.Kind == model.KindArtistArtwork {
|
||||||
reader, _ = resources.FS().Open(consts.PlaceholderArtistArt)
|
reader, _ = resources.FS().Open(consts.PlaceholderArtistArt)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -70,7 +87,17 @@ func (a *artwork) Get(ctx context.Context, artID model.ArtworkID, size int, squa
|
||||||
}
|
}
|
||||||
return nil, time.Time{}, err
|
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 {
|
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)
|
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
|
// noopProvider implements external.Provider with not-found returns so the
|
||||||
// "external" priority entry never produces a result.
|
// "external" priority entry never produces a result.
|
||||||
type noopProvider struct{}
|
type noopProvider struct{}
|
||||||
|
|
|
||||||
|
|
@ -91,10 +91,22 @@ func setupHarness() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func scan() {
|
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()
|
GinkgoHelper()
|
||||||
s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
|
s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
|
||||||
playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance())
|
playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance())
|
||||||
_, err := s.ScanAll(ctx, true)
|
_, err := s.ScanAll(ctx, full)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -66,8 +66,12 @@ func resizedFromOriginal(ctx context.Context, a *artwork, artID model.ArtworkID,
|
||||||
return r, nil
|
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 {
|
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 {
|
if a.square {
|
||||||
return baseKey + ".square"
|
return baseKey + ".square"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,28 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||||
"github.com/navidrome/navidrome/tests"
|
"github.com/navidrome/navidrome/tests"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "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 _ = Describe("resizeImage", func() {
|
||||||
var mockFF *tests.MockFFmpeg
|
var mockFF *tests.MockFFmpeg
|
||||||
var r *resizedArtworkReader
|
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
|
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
|
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
|
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 {
|
func (a Album) CoverArtID() ArtworkID {
|
||||||
return artworkIDFromAlbum(a)
|
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 {
|
func (a Album) FullName() string {
|
||||||
if conf.Server.Subsonic.AppendAlbumVersion && len(a.Tags[TagAlbumVersion]) > 0 {
|
if conf.Server.Subsonic.AppendAlbumVersion && len(a.Tags[TagAlbumVersion]) > 0 {
|
||||||
return fmt.Sprintf("%s (%s)", a.Name, 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)
|
Exists(id string) (bool, error)
|
||||||
Put(*Album) error
|
Put(*Album) error
|
||||||
UpdateExternalInfo(*Album) error
|
UpdateExternalInfo(*Album) error
|
||||||
|
UpdateBlurHash(id string, blurHash string, artworkUpdatedAt time.Time) error
|
||||||
Get(id string) (*Album, error)
|
Get(id string) (*Album, error)
|
||||||
GetAll(...QueryOptions) (Albums, error)
|
GetAll(...QueryOptions) (Albums, error)
|
||||||
GetCursor(...QueryOptions) (AlbumCursor, error)
|
GetCursor(...QueryOptions) (AlbumCursor, error)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package model_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
"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"`
|
CreatedAt *time.Time `structs:"created_at" json:"createdAt,omitempty"`
|
||||||
UpdatedAt *time.Time `structs:"updated_at" json:"updatedAt,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 {
|
type ArtistStats struct {
|
||||||
|
|
@ -63,6 +66,16 @@ func (a Artist) CoverArtID() ArtworkID {
|
||||||
return artworkIDFromArtist(a)
|
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 {
|
func (a Artist) UploadedImagePath() string {
|
||||||
return UploadedImagePath(consts.EntityArtist, a.UploadedImage)
|
return UploadedImagePath(consts.EntityArtist, a.UploadedImage)
|
||||||
}
|
}
|
||||||
|
|
@ -87,6 +100,7 @@ type ArtistRepository interface {
|
||||||
Exists(id string) (bool, error)
|
Exists(id string) (bool, error)
|
||||||
Put(m *Artist, colsToUpdate ...string) error
|
Put(m *Artist, colsToUpdate ...string) error
|
||||||
UpdateExternalInfo(a *Artist) error
|
UpdateExternalInfo(a *Artist) error
|
||||||
|
UpdateBlurHash(id string, blurHash string, artworkUpdatedAt time.Time) error
|
||||||
Get(id string) (*Artist, error)
|
Get(id string) (*Artist, error)
|
||||||
GetAll(options ...QueryOptions) (Artists, error)
|
GetAll(options ...QueryOptions) (Artists, error)
|
||||||
GetCursor(options ...QueryOptions) (ArtistCursor, error)
|
GetCursor(options ...QueryOptions) (ArtistCursor, error)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package model_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
"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"`
|
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
|
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
|
// SmartPlaylist attributes
|
||||||
Rules *criteria.Criteria `structs:"rules" json:"rules"`
|
Rules *criteria.Criteria `structs:"rules" json:"rules"`
|
||||||
EvaluatedAt *time.Time `structs:"evaluated_at" json:"evaluatedAt"`
|
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
|
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
|
// RefreshDelay returns the playlist's own refresh window when set, falling
|
||||||
// back to the global SmartPlaylistRefreshDelay.
|
// back to the global SmartPlaylistRefreshDelay.
|
||||||
func (pls Playlist) RefreshDelay() time.Duration {
|
func (pls Playlist) RefreshDelay() time.Duration {
|
||||||
|
|
@ -145,6 +152,7 @@ type PlaylistRepository interface {
|
||||||
GetAll(options ...QueryOptions) (Playlists, error)
|
GetAll(options ...QueryOptions) (Playlists, error)
|
||||||
GetCursor(options ...QueryOptions) (PlaylistCursor, error)
|
GetCursor(options ...QueryOptions) (PlaylistCursor, error)
|
||||||
FindByPath(path string) (*Playlist, error)
|
FindByPath(path string) (*Playlist, error)
|
||||||
|
UpdateBlurHash(id string, blurHash string, artworkUpdatedAt time.Time) error
|
||||||
Delete(id string) error
|
Delete(id string) error
|
||||||
Tracks(playlistId string, refreshSmartPlaylist bool) PlaylistTrackRepository
|
Tracks(playlistId string, refreshSmartPlaylist bool) PlaylistTrackRepository
|
||||||
GetPlaylists(mediaFileId string) (Playlists, error)
|
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
|
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
|
// TODO Move external metadata to a separated table
|
||||||
func (r *albumRepository) UpdateExternalInfo(al *model.Album) error {
|
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")
|
_, 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 {
|
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")
|
LeftJoin("library on album.library_id = library.id")
|
||||||
sql = r.withAnnotation(sql, "album.id")
|
sql = r.withAnnotation(sql, "album.id")
|
||||||
return r.applyLibraryFilter(sql)
|
return r.applyLibraryFilter(sql)
|
||||||
|
|
|
||||||
|
|
@ -899,3 +899,141 @@ func _p(id, name string, sortName ...string) model.Participant {
|
||||||
}
|
}
|
||||||
return p
|
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
|
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 {
|
func (r *artistRepository) UpdateExternalInfo(a *model.Artist) error {
|
||||||
dba := &dbArtist{Artist: a}
|
dba := &dbArtist{Artist: a}
|
||||||
_, err := r.put(a.ID, dba,
|
_, err := r.put(a.ID, dba,
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,10 @@ func (r *playlistRepository) GetWithTracks(id string, refreshSmartPlaylist, incl
|
||||||
return pls, nil
|
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) {
|
func (r *playlistRepository) FindByPath(path string) (*model.Playlist, error) {
|
||||||
return r.findBy(Eq{"path": path})
|
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) {
|
func (r sqlRepository) executeSQL(sq Sqlizer) (int64, error) {
|
||||||
query, args, err := r.toSQL(sq)
|
query, args, err := r.toSQL(sq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"slices"
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/core/storage"
|
"github.com/navidrome/navidrome/core/storage"
|
||||||
|
|
@ -160,7 +161,13 @@ func loadDir(ctx context.Context, job *scanJob, dirPath string, checker *IgnoreC
|
||||||
folder.numPlaylists++
|
folder.numPlaylists++
|
||||||
case model.IsImageFile(name):
|
case model.IsImageFile(name):
|
||||||
folder.imageFiles[entry.Name()] = entry
|
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
|
package dto
|
||||||
|
|
||||||
import "hash/fnv"
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
// base83Alphabet is the blurhash spec's base83 encoding alphabet; order is part of the spec.
|
// primaryBlurHash returns the stored blurhash when current for the artwork version, else "" so the
|
||||||
const base83Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"
|
// 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 {
|
||||||
// base83 encodes value as a fixed-width, big-endian base83 string of the given length.
|
if stored != "" && storedAt != nil && !storedAt.Before(version) {
|
||||||
func base83(value, length int) string {
|
return stored
|
||||||
b := make([]byte, length)
|
|
||||||
for i := 1; i <= length; i++ {
|
|
||||||
digit := (value / pow83(length-i)) % 83
|
|
||||||
b[i-1] = base83Alphabet[digit]
|
|
||||||
}
|
}
|
||||||
return string(b)
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func pow83(n int) int {
|
// primaryBlurHashes builds the ImageBlurHashes map for a known-current hash, or nil so the field is
|
||||||
result := 1
|
// omitted entirely when there is none.
|
||||||
for range n {
|
func primaryBlurHashes(tag, hash string) map[string]map[string]string {
|
||||||
result *= 83
|
if hash == "" {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
return result
|
return map[string]map[string]string{"Primary": {tag: hash}}
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,41 @@
|
||||||
package dto
|
package dto
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"time"
|
||||||
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
|
|
||||||
var _ = Describe("blurHash", func() {
|
var _ = Describe("primaryBlurHash", func() {
|
||||||
It("returns a 6-char valid blurhash starting with the 1x1 component prefix", func() {
|
version := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
h := blurHash("x")
|
|
||||||
Expect(h).To(HaveLen(6))
|
It("returns the stored hash when it matches the current artwork version", func() {
|
||||||
Expect(h).To(HavePrefix("00"))
|
Expect(primaryBlurHash("LEHV6nWB2yk8", &version, version)).To(Equal("LEHV6nWB2yk8"))
|
||||||
for _, c := range h {
|
|
||||||
Expect(strings.ContainsRune(base83Alphabet, c)).To(BeTrue(), "unexpected char %q", c)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
It("is deterministic for the same seed", func() {
|
It("returns the stored hash when the snapshot is newer than the version (image mtime)", func() {
|
||||||
Expect(blurHash("cover-tag-1")).To(Equal(blurHash("cover-tag-1")))
|
newer := version.Add(time.Hour)
|
||||||
|
Expect(primaryBlurHash("LEHV6nWB2yk8", &newer, version)).To(Equal("LEHV6nWB2yk8"))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("differs for different seeds", func() {
|
It("omits when there is no stored hash", func() {
|
||||||
Expect(blurHash("cover-tag-1")).ToNot(Equal(blurHash("cover-tag-2")))
|
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 != "" {
|
} else if mf.Genre != "" {
|
||||||
item.Genres = []string{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 != "" {
|
if mf.AlbumID != "" {
|
||||||
item.AlbumPrimaryImageTag = mf.AlbumID
|
item.AlbumPrimaryImageTag = mf.AlbumID
|
||||||
item.ImageBlurHashes = map[string]map[string]string{"Primary": {mf.AlbumID: blurHash(mf.AlbumID)}}
|
|
||||||
}
|
}
|
||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
|
@ -201,7 +201,7 @@ func AlbumToBaseItem(al model.Album) BaseItemDto {
|
||||||
RunTimeTicks: TicksFromSeconds(al.Duration),
|
RunTimeTicks: TicksFromSeconds(al.Duration),
|
||||||
DateCreated: jellyfinDate(&al.CreatedAt),
|
DateCreated: jellyfinDate(&al.CreatedAt),
|
||||||
ImageTags: map[string]string{"Primary": al.ID},
|
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{},
|
BackdropImageTags: []string{},
|
||||||
UserData: UserData(al.Annotations, al.ID),
|
UserData: UserData(al.Annotations, al.ID),
|
||||||
}
|
}
|
||||||
|
|
@ -231,7 +231,7 @@ func ArtistToBaseItem(ar model.Artist) BaseItemDto {
|
||||||
SongCount: new(ar.SongCount),
|
SongCount: new(ar.SongCount),
|
||||||
DateCreated: jellyfinDate(ar.CreatedAt),
|
DateCreated: jellyfinDate(ar.CreatedAt),
|
||||||
ImageTags: map[string]string{"Primary": ar.ID},
|
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{},
|
BackdropImageTags: []string{},
|
||||||
UserData: UserData(ar.Annotations, ar.ID),
|
UserData: UserData(ar.Annotations, ar.ID),
|
||||||
}
|
}
|
||||||
|
|
@ -264,7 +264,7 @@ func PlaylistToBaseItem(p model.Playlist) BaseItemDto {
|
||||||
ChildCount: new(p.SongCount),
|
ChildCount: new(p.SongCount),
|
||||||
RunTimeTicks: TicksFromSeconds(p.Duration),
|
RunTimeTicks: TicksFromSeconds(p.Duration),
|
||||||
ImageTags: map[string]string{"Primary": tag},
|
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{},
|
BackdropImageTags: []string{},
|
||||||
UserData: UserData(p.Annotations, p.ID),
|
UserData: UserData(p.Annotations, p.ID),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,9 @@ var _ = Describe("mappers", func() {
|
||||||
Expect(item.UserData.Played).To(BeTrue())
|
Expect(item.UserData.Played).To(BeTrue())
|
||||||
Expect(item.UserData.Key).To(Equal(EncodeID("song-1")))
|
Expect(item.UserData.Key).To(Equal(EncodeID("song-1")))
|
||||||
Expect(item.UserData.ItemId).To(Equal(EncodeID("song-1")))
|
Expect(item.UserData.ItemId).To(Equal(EncodeID("song-1")))
|
||||||
Expect(item.ImageBlurHashes["Primary"]).To(HaveKey(item.AlbumPrimaryImageTag))
|
Expect(item.AlbumPrimaryImageTag).To(Equal("alb-1"))
|
||||||
Expect(item.ImageBlurHashes["Primary"][item.AlbumPrimaryImageTag]).To(HaveLen(6))
|
// 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() {
|
Describe("Fields gating (matches real Jellyfin)", func() {
|
||||||
|
|
@ -209,8 +210,8 @@ var _ = Describe("mappers", func() {
|
||||||
Expect(item.ArtistItems).To(Equal(item.AlbumArtists))
|
Expect(item.ArtistItems).To(Equal(item.AlbumArtists))
|
||||||
Expect(*item.ProductionYear).To(Equal(1999))
|
Expect(*item.ProductionYear).To(Equal(1999))
|
||||||
Expect(*item.ChildCount).To(Equal(10))
|
Expect(*item.ChildCount).To(Equal(10))
|
||||||
Expect(item.ImageBlurHashes["Primary"]).To(HaveKey(item.ImageTags["Primary"]))
|
// No stored blurhash: the field is omitted, never fabricated.
|
||||||
Expect(item.ImageBlurHashes["Primary"][item.ImageTags["Primary"]]).To(HaveLen(6))
|
Expect(item.ImageBlurHashes).To(BeNil())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("maps an artist to a MusicArtist folder item", func() {
|
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))
|
Expect(*item.UserData.Rating).To(Equal(8.0))
|
||||||
tag := item.ImageTags["Primary"]
|
tag := item.ImageTags["Primary"]
|
||||||
Expect(tag).ToNot(BeEmpty())
|
Expect(tag).ToNot(BeEmpty())
|
||||||
Expect(item.ImageBlurHashes["Primary"]).To(HaveKey(tag))
|
// No stored blurhash on this playlist: omitted, never fabricated.
|
||||||
Expect(item.ImageBlurHashes["Primary"][tag]).To(HaveLen(6))
|
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)}
|
p := model.Playlist{ID: "pl-1", Name: "Chill", UpdatedAt: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)}
|
||||||
before := PlaylistToBaseItem(p)
|
before := PlaylistToBaseItem(p)
|
||||||
p.UpdatedAt = time.Date(2026, 7, 2, 0, 0, 0, 0, time.UTC)
|
p.UpdatedAt = time.Date(2026, 7, 2, 0, 0, 0, 0, time.UTC)
|
||||||
after := PlaylistToBaseItem(p)
|
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.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() {
|
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)))
|
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
|
// 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.
|
// 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)
|
plID := createPlaylist("Cover Tag", nil)
|
||||||
imageTag := func() string {
|
imageTag := func() string {
|
||||||
q := queryResult(get("/Items?ids=" + enc(plID)))
|
q := queryResult(get("/Items?ids=" + enc(plID)))
|
||||||
|
|
@ -233,8 +233,10 @@ var _ = Describe("Playlists", func() {
|
||||||
|
|
||||||
after := imageTag()
|
after := imageTag()
|
||||||
Expect(after).ToNot(Equal(before))
|
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)))
|
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
|
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) {
|
func (m *MockAlbumRepo) SetData(albums model.Albums) {
|
||||||
m.Data = make(map[string]*model.Album, len(albums))
|
m.Data = make(map[string]*model.Album, len(albums))
|
||||||
m.All = 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) {
|
func (m *MockArtistRepo) Exists(id string) (bool, error) {
|
||||||
if m.Err {
|
if m.Err {
|
||||||
return false, errors.New("Error!")
|
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) {
|
func (m *MockPlaylistRepo) GetAll(options ...model.QueryOptions) (model.Playlists, error) {
|
||||||
if len(options) > 0 {
|
if len(options) > 0 {
|
||||||
m.Options = options[0]
|
m.Options = options[0]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue