Revert "feat(jellyfin): send a synthetic placeholder blurhash for unresolved artwork (#5941)"

This reverts commit 036c9cab9671505c83fce6523f55d97b152b3b32.
This commit is contained in:
Deluan 2026-08-12 11:46:23 -04:00
parent 036c9cab96
commit 8978c7b9fa
6 changed files with 38 additions and 363 deletions

View File

@ -32,32 +32,23 @@ func Encode(img image.Image) (string, error) {
}
// Pre-downscale: its rounding can flip a component count, and the hash is a client cache key.
xComp, yComp := components(img.Bounds().Dx(), img.Bounds().Dy())
return encodeAt(img, xComp, yComp), nil
}
// encodeAt encodes img at the given component counts (each 2..9). Callers guarantee non-empty
// bounds, and a source far larger than the counts: too few samples overshoot the AC terms.
func encodeAt(img image.Image, xComp, yComp int) string {
src := pixelsOf(downscale(img))
return encodePixels(src, xComp, yComp, cosBasis(xComp, src.w), cosBasis(yComp, src.h))
}
w, h := src.w, src.h
// cosBasis is the cosine basis for comp components sampled across n pixels. It depends only
// on its arguments, so a caller with a fixed shape can build it once and reuse it.
func cosBasis(comp, n int) [][]float64 {
basis := make([][]float64, comp)
for i := range basis {
basis[i] = make([]float64, n)
for x := range basis[i] {
basis[i][x] = math.Cos(math.Pi * float64(i) * float64(x) / float64(n))
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))
}
}
return basis
}
// encodePixels is the encoder proper; cosX and cosY must match src's dimensions.
func encodePixels(src pixels, xComp, yComp int, cosX, cosY [][]float64) string {
w, h := src.w, src.h
lin := srgbToLinearTable()
factors := make([][3]float64, xComp*yComp)
@ -95,7 +86,7 @@ func encodePixels(src pixels, xComp, yComp int, cosX, cosY [][]float64) string {
var sb strings.Builder
sb.WriteString(encode83((xComp-1)+(yComp-1)*9, 1))
// Every caller passes at least 2 components, so there is always at least one AC factor.
// Derived counts are at least 1x9, so there is always at least one AC factor.
ac := factors[1:]
actualMax := 0.0
for _, f := range ac {
@ -110,7 +101,7 @@ func encodePixels(src pixels, xComp, yComp int, cosX, cosY [][]float64) string {
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()
return sb.String(), nil
}
// pixels is direct Pix access for the pixel loop, avoiding a per-pixel allocation via image.At.

View File

@ -1,172 +0,0 @@
package blurhash
import (
"image"
"math"
"strconv"
"sync"
"github.com/zeebo/xxh3"
)
// A real hash never has 3x3 components: components() always targets ~16 tiles.
const synthComponents = 3
// Encoding a source no bigger than the component grid overshoots the AC coefficients,
// clamping the corners to black; the encoder assumes many samples per component.
const synthSourceSize = 8
type colorGrid = [synthComponents * synthComponents][3]float64
// Synthetic returns a blurhash unique to seed, for artwork whose real hash does not exist
// yet. baseColor ("#rrggbb") sets the hue family; "" derives it from the seed.
func Synthetic(seed, baseColor string) string {
hue, sat, light := baseTone(baseColor, xxh3.HashStringSeed(seed, 0))
var grid colorGrid
for i := range grid {
// Each cell hashes the seed separately, so one tint shared by many items still
// yields one value per item.
bits := xxh3.HashStringSeed(seed, uint64(i)+1)
dh := (byteFrac(bits, 0) - 0.5) * 60
ds := (byteFrac(bits, 8) - 0.5) * 0.16
dl := (byteFrac(bits, 16) - 0.5) * 0.30
r, g, b := hslToRGB(hue+dh, clamp01(sat+ds), clamp01(light+dl))
grid[i] = [3]float64{float64(r), float64(g), float64(b)}
}
// The shape is fixed, so the cosine basis is reused instead of rebuilt per call.
basis := synthBasis()
return encodePixels(pixelsOf(upscale(&grid)), synthComponents, synthComponents, basis, basis)
}
// synthBasis is the cosine basis for the fixed synthetic shape. Square, so one serves both axes.
var synthBasis = sync.OnceValue(func() [][]float64 {
return cosBasis(synthComponents, synthSourceSize)
})
// upscale renders the cell grid bilinearly at synthSourceSize. Hand-rolled because
// x/image's scaler allocates per call, and this runs once per mapped item.
func upscale(grid *colorGrid) *image.NRGBA {
const n, size = synthComponents, synthSourceSize
axis := axisWeights()
// Separable: each grid row is stretched horizontally once, then rows blend vertically.
// Doing it per pixel instead would repeat every horizontal lerp `size` times.
var rows [n][size][3]float64
for r := range n {
for x, a := range axis {
for c := range 3 {
rows[r][x][c] = grid[r*n+a.cell][c]*(1-a.frac) + grid[r*n+a.cell+1][c]*a.frac
}
}
}
img := image.NewNRGBA(image.Rect(0, 0, size, size))
for y, a := range axis {
top, bot := &rows[a.cell], &rows[a.cell+1]
for x := range size {
p := img.Pix[y*img.Stride+x*4:]
for c := range 3 {
p[c] = uint8(top[x][c]*(1-a.frac) + bot[x][c]*a.frac + 0.5)
}
p[3] = 255
}
}
return img
}
// axisWeights maps each source pixel to its lower cell and the fraction toward the next.
// Both axes are square and constant-sized, so one table serves both and outlives the call.
var axisWeights = sync.OnceValue(func() *[synthSourceSize]struct {
cell int
frac float64
} {
var t [synthSourceSize]struct {
cell int
frac float64
}
for i := range t {
// Edge pixels fall outside the cell centres, so both ends clamp rather than extrapolate.
f := min(max((float64(i)+0.5)*synthComponents/synthSourceSize-0.5, 0), synthComponents-1)
t[i].cell = min(int(f), synthComponents-2)
t[i].frac = f - float64(t[i].cell)
}
return &t
})
func byteFrac(bits uint64, shift int) float64 {
return float64(bits>>shift&0xFF) / 255
}
// baseTone clamps saturation and lightness away from the extremes, so a placeholder
// never reads as neon or as solid black.
func baseTone(baseColor string, hueBits uint64) (hue, sat, light float64) {
if r, g, b, ok := parseHex(baseColor); ok {
h, s, l := rgbToHSL(r, g, b)
return h, min(max(s, 0.10), 0.35), min(max(l, 0.15), 0.75)
}
return float64(hueBits&0x1FF) / 512 * 360, 0.22, 0.40
}
func parseHex(s string) (r, g, b uint8, ok bool) {
if len(s) != 7 || s[0] != '#' {
return 0, 0, 0, false
}
v, err := strconv.ParseUint(s[1:], 16, 32)
if err != nil {
return 0, 0, 0, false
}
return uint8(v >> 16), uint8(v >> 8), uint8(v), true
}
func rgbToHSL(r, g, b uint8) (h, s, l float64) {
rf, gf, bf := float64(r)/255, float64(g)/255, float64(b)/255
mx, mn := max(rf, gf, bf), min(rf, gf, bf)
l = (mx + mn) / 2
if mx == mn {
return 0, 0, l
}
d := mx - mn
s = d / (1 - math.Abs(2*l-1))
switch mx {
case rf:
h = math.Mod((gf-bf)/d, 6)
case gf:
h = (bf-rf)/d + 2
default:
h = (rf-gf)/d + 4
}
h *= 60
if h < 0 {
h += 360
}
return h, s, l
}
func hslToRGB(h, s, l float64) (uint8, uint8, uint8) {
h = math.Mod(math.Mod(h, 360)+360, 360)
c := (1 - math.Abs(2*l-1)) * s
hp := h / 60
x := c * (1 - math.Abs(math.Mod(hp, 2)-1))
var r, g, b float64
switch int(hp) {
case 0:
r, g, b = c, x, 0
case 1:
r, g, b = x, c, 0
case 2:
r, g, b = 0, c, x
case 3:
r, g, b = 0, x, c
case 4:
r, g, b = x, 0, c
default:
r, g, b = c, 0, x
}
m := l - c/2
return to8(r + m), to8(g + m), to8(b + m)
}
func clamp01(v float64) float64 { return min(max(v, 0), 1) }
func to8(v float64) uint8 { return uint8(math.Round(clamp01(v) * 255)) }

View File

@ -1,103 +0,0 @@
package blurhash_test
import (
"fmt"
"strings"
"github.com/navidrome/navidrome/core/artwork/blurhash"
"github.com/zeebo/xxh3"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Synthetic", func() {
It("returns a well-formed 3x3 blurhash", func() {
h := blurhash.Synthetic("alb-1", "")
// 3x3 encodes as (3-1)+(3-1)*9 = 20, which is base83 'K'; length is 1+1+4+8*2.
Expect(h).To(HaveLen(22))
Expect(h).To(HavePrefix("K"))
for _, c := range h {
Expect(strings.ContainsRune(alphabet, c)).To(BeTrue(), "unexpected char %q", c)
}
})
It("keeps its prefix exclusive to real encodes, across aspect ratios", func() {
Expect(blurhash.Synthetic("alb-1", "")).To(HavePrefix("K"))
ratios := []struct{ w, h int }{
{10, 200}, {200, 10}, {1, 50}, {50, 1}, {64, 64}, {100, 300}, {300, 100},
}
for _, r := range ratios {
encoded, err := blurhash.Encode(gradientImage(r.w, r.h))
Expect(err).ToNot(HaveOccurred())
Expect(encoded).ToNot(HavePrefix("K"), "real encode at %dx%d produced the synthetic prefix", r.w, r.h)
}
})
It("is deterministic for one seed", func() {
Expect(blurhash.Synthetic("alb-1", "")).To(Equal(blurhash.Synthetic("alb-1", "")))
})
It("differs across seeds", func() {
Expect(blurhash.Synthetic("alb-1", "")).ToNot(Equal(blurhash.Synthetic("alb-2", "")))
})
dcOf := func(hash string) (int, int, int) {
dc := decode83(hash[2:6])
return dc >> 16 & 0xFF, dc >> 8 & 0xFF, dc & 0xFF
}
It("decodes to a muted DC colour", func() {
r, g, b := dcOf(blurhash.Synthetic("alb-1", ""))
spread := max(r, g, b) - min(r, g, b)
Expect(spread).To(BeNumerically("<", 120), "saturation is capped, so no channel should run away")
Expect(max(r, g, b)).To(BeNumerically("<", 240), "lightness is capped below white")
Expect(min(r, g, b)).To(BeNumerically(">", 10), "lightness is capped above black")
})
It("keeps 1,000,000 seeds effectively distinct", func() {
const count = 1_000_000
seen := make(map[uint64]struct{}, count)
for i := range count {
seen[xxh3.HashString(blurhash.Synthetic(fmt.Sprintf("item-%d", i), ""))] = struct{}{}
}
Expect(len(seen)).To(BeNumerically(">=", 999_900))
})
It("leans the DC towards a red tint", func() {
r, g, b := dcOf(blurhash.Synthetic("alb-1", "#c04040"))
Expect(r).To(BeNumerically(">", g))
Expect(r).To(BeNumerically(">", b))
})
It("leans the DC towards a blue tint", func() {
r, g, b := dcOf(blurhash.Synthetic("alb-1", "#4040c0"))
Expect(b).To(BeNumerically(">", r))
Expect(b).To(BeNumerically(">", g))
})
It("changes the value when only the tint changes", func() {
Expect(blurhash.Synthetic("alb-1", "#c04040")).ToNot(Equal(blurhash.Synthetic("alb-1", "#4040c0")))
})
It("falls back to the seed hue for an unparseable tint", func() {
Expect(blurhash.Synthetic("alb-1", "not-a-colour")).To(Equal(blurhash.Synthetic("alb-1", "")))
})
It("tracks a dark tint's lightness", func() {
dark, _, _ := dcOf(blurhash.Synthetic("alb-1", "#101820"))
light, _, _ := dcOf(blurhash.Synthetic("alb-1", "#e8f0f8"))
Expect(dark).To(BeNumerically("<", light))
})
// A shared tint must not become a shared value: the seed alone carries uniqueness.
It("keeps 1,000,000 seeds distinct under a single fixed tint", func() {
const count = 1_000_000
seen := make(map[uint64]struct{}, count)
for i := range count {
seen[xxh3.HashString(blurhash.Synthetic(fmt.Sprintf("track-%d", i), "#3a5f7d"))] = struct{}{}
}
Expect(len(seen)).To(BeNumerically(">=", 999_900))
})
})

View File

@ -180,14 +180,6 @@ warmer uses — so user-scoped items like private playlists still resolve their
falling back to the placeholder. Album, artist, media-file and playlist ids are all resolved to
their Navidrome `ArtworkID`.
Blurhashes come in three tiers. A real blurhash is computed once in
`core/artwork` from the decoded image and stored per artwork row; the mappers read it whenever it
exists. While artwork is still unresolved, `dto/mappers.go` instead emits a synthetic 3x3 blurhash
(`core/artwork/blurhash.Synthetic`), seeded on the image tag so it's effectively unique per image
and can never collide with a real hash's leading byte, giving clients a valid cache key while art
loads. A track awaiting embedded-art extraction has its synthetic hash tinted from the parent
album's dominant colour. Known-absent artwork emits no tag and no blurhash at all.
## Finamp saved-queue id truncation
Real Jellyfin item ids are GUIDs — 128-bit values, always 32 hex characters. Finamp relies on that
@ -338,6 +330,16 @@ make test PKG=./server/jellyfin/...
Access control for artists is enforced by scoping the `Artists`/`Items?IncludeItemTypes=MusicArtist`
*list* to the user's libraries, plus the persistence layer's own defense-in-depth; a client
that already has an artist id from elsewhere is not re-checked against library membership.
- **Blurhashes are synthetic, not computed from the artwork (follow-up).** `ImageBlurHashes` is
populated by `dto/blurhash.go`, which derives a well-formed **1-component (solid color)**
blurhash by hashing the item id — it never looks at the actual image. Real Jellyfin computes a
multi-component blurhash from the cover's pixels (downscaled to 128×128) once at scan time and
stores it per image, so its placeholder approximates the art. Ours satisfies the protocol
(Finamp gets a valid value to use as a de-dup key and a placeholder, no missing-blurhash
warning) but renders as a flat color while art loads. A proper implementation would compute the
real blurhash in the `core/artwork` pipeline (where the image is already decoded), cache it
keyed like the artwork, and have the mappers read it — keeping the synthetic value as a fallback
for art that hasn't been rendered yet.
- **The WebSocket only keep-alives; it pushes no events (follow-up).** `GET socket` sends a
`ForceKeepAlive` and answers `KeepAlive` pings so real-time clients (Finamp) settle into a
working session instead of 404-loop-reconnecting, but it never pushes anything. A follow-up

View File

@ -6,7 +6,6 @@ import (
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/artwork/blurhash"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/slice"
)
@ -213,8 +212,6 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto {
// Nothing enqueues media files: advertising the id is what makes a client ask, and that
// request is what extracts the embedded art and queues the track.
item.ImageTags = map[string]string{"Primary": mf.ID}
hash := blurhash.Synthetic(mf.ID, mf.AlbumImage.DominantColor)
item.ImageBlurHashes = map[string]map[string]string{"Primary": {mf.ID: hash}}
} else if mf.AlbumID != "" {
if tag, blurs, ratio := primaryImage(mf.AlbumImage, mf.AlbumID, fields); tag != "" {
item.AlbumPrimaryImageTag = tag
@ -230,19 +227,15 @@ func embeddedArtPending(mf model.MediaFile) bool {
mf.ImageHash == "" && !mf.ItemImage.ImageAbsent
}
// primaryImage synthesizes a blurhash when none was computed yet: clients key their cover
// cache on the value, so it must be unique per image, never shared or reused.
// primaryImage never fakes a blurhash: clients key their cover cache on the value, which would
// pin a stale cover forever.
func primaryImage(img model.ItemImage, fallback string, fields Fields) (tag string, blurs map[string]map[string]string, ratio *float64) {
if img.ImageAbsent {
return "", nil, nil
}
tag = cmp.Or(img.ImageHash, fallback)
if tag != "" {
hash := img.BlurHash
if hash == "" {
hash = blurhash.Synthetic(tag, "")
}
blurs = map[string]map[string]string{"Primary": {tag: hash}}
if img.BlurHash != "" {
blurs = map[string]map[string]string{"Primary": {tag: img.BlurHash}}
}
if fields.Has("PrimaryImageAspectRatio") {
ratio = img.AspectRatio()

View File

@ -6,7 +6,6 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/artwork/blurhash"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@ -38,7 +37,7 @@ var _ = Describe("mappers", func() {
Expect(item.UserData.Key).To(Equal(EncodeID("song-1")))
Expect(item.UserData.ItemId).To(Equal(EncodeID("song-1")))
Expect(item.AlbumPrimaryImageTag).To(Equal("alb-1"))
Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("alb-1", blurhash.Synthetic("alb-1", "")))
Expect(item.ImageBlurHashes).To(BeNil())
Expect(item.Genres).To(Equal([]string{"genre 1", "genre 2"}))
Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID("1"), Name: "genre 1"}, {Id: EncodeID("2"), Name: "genre 2"}}))
})
@ -299,7 +298,7 @@ var _ = Describe("mappers", func() {
Expect(*item.ProductionYear).To(Equal(1999))
Expect(*item.ChildCount).To(Equal(10))
Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "alb-1"))
Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("alb-1", blurhash.Synthetic("alb-1", "")))
Expect(item.ImageBlurHashes).To(BeNil())
Expect(item.Genres).To(Equal([]string{"genre 1", "genre 2"}))
Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID("1"), Name: "genre 1"}, {Id: EncodeID("2"), Name: "genre 2"}}))
})
@ -473,7 +472,7 @@ var _ = Describe("mappers", func() {
Expect(item.UserData.PlayCount).To(Equal(2))
Expect(*item.UserData.Rating).To(Equal(8.0))
Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "pl-1"))
Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("pl-1", blurhash.Synthetic("pl-1", "")))
Expect(item.ImageBlurHashes).To(BeNil())
})
It("changes the playlist image tag when the cover content changes", func() {
@ -508,37 +507,13 @@ var _ = Describe("mappers", func() {
It("advertises the track id so the client triggers the read-through", func() {
mf := model.MediaFile{ID: "mf-1", AlbumID: "alb-1", HasCoverArt: true}
mf.AlbumImage.ImageHash = "0123456789abcdef"
mf.AlbumImage.DominantColor = "#3a5f7d"
item := SongToBaseItem(mf, nil)
Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "mf-1"))
Expect(item.ImageBlurHashes["Primary"]).To(
HaveKeyWithValue("mf-1", blurhash.Synthetic("mf-1", "#3a5f7d")))
Expect(item.ImageBlurHashes).To(BeNil(), "no resolved image means no blurhash to send")
Expect(item.AlbumPrimaryImageTag).To(BeEmpty())
})
// The client's image request is what extracts the embedded art. Reusing the album's
// value would let it answer from cache and never send it.
It("gives the track a value distinct from its album's", func() {
mf := model.MediaFile{ID: "mf-3", AlbumID: "alb-1", HasCoverArt: true}
mf.AlbumImage.ImageHash = "0123456789abcdef"
mf.AlbumImage.BlurHash = "LEHV6nWB2yk8"
mf.AlbumImage.DominantColor = "#3a5f7d"
track := SongToBaseItem(mf, nil).ImageBlurHashes["Primary"]["mf-3"]
album := AlbumToBaseItem(model.Album{ID: "alb-1", ItemImage: mf.AlbumImage}, nil).
ImageBlurHashes["Primary"]["0123456789abcdef"]
Expect(track).ToNot(BeEmpty())
Expect(track).ToNot(Equal(album))
})
It("still emits a value when the album has no dominant colour", func() {
mf := model.MediaFile{ID: "mf-4", AlbumID: "alb-1", HasCoverArt: true}
item := SongToBaseItem(mf, nil)
Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("mf-4", blurhash.Synthetic("mf-4", "")))
})
It("falls back to the album when the track has no art of its own", func() {
mf := model.MediaFile{ID: "mf-2", AlbumID: "alb-1", HasCoverArt: false}
mf.AlbumImage.ImageHash = "0123456789abcdef"
@ -580,14 +555,13 @@ var _ = Describe("mappers", func() {
Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("0123456789abcdef", "LEHV6nWB2yk8"))
})
It("synthesizes a blurhash when none was computed", func() {
It("omits the blurhash entirely when none was computed", func() {
al := model.Album{ID: "alb-2", Name: "Album"}
al.ImageHash = "0123456789abcdef"
item := AlbumToBaseItem(al, nil)
Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "0123456789abcdef"))
Expect(item.ImageBlurHashes["Primary"]).To(
HaveKeyWithValue("0123456789abcdef", blurhash.Synthetic("0123456789abcdef", "")))
Expect(item.ImageBlurHashes).To(BeNil(), "a synthesized blurhash pins stale covers in Finamp")
})
It("omits tags for known-absent artwork", func() {
@ -599,19 +573,10 @@ var _ = Describe("mappers", func() {
Expect(item.ImageBlurHashes).To(BeNil())
})
It("keeps known-absent artwork free of any synthesized value", func() {
al := model.Album{ID: "alb-5", Name: "Album"}
al.ImageAbsent = true
item := AlbumToBaseItem(al, nil)
Expect(item.ImageBlurHashes).To(BeNil(),
"there is no image to key a cache on, and nothing will ever re-key it")
})
It("falls back to the entity id while artwork is still unresolved", func() {
item := AlbumToBaseItem(model.Album{ID: "alb-4", Name: "Album"}, nil)
Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "alb-4"))
Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("alb-4", blurhash.Synthetic("alb-4", "")))
Expect(item.ImageBlurHashes).To(BeNil())
})
It("versions an artist's tag by content hash", func() {
@ -636,14 +601,13 @@ var _ = Describe("mappers", func() {
Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("0123456789abcdef", "LEHV6nWB2yk8"))
})
It("synthesizes a song's album blurhash when the album has none", func() {
It("never synthesizes a song blurhash when the album has none", func() {
mf := model.MediaFile{ID: "song-2", Title: "Song", AlbumID: "alb-2"}
mf.AlbumImage.ImageHash = "0123456789abcdef"
item := SongToBaseItem(mf, nil)
Expect(item.AlbumPrimaryImageTag).To(Equal("0123456789abcdef"))
Expect(item.ImageBlurHashes["Primary"]).To(
HaveKeyWithValue("0123456789abcdef", blurhash.Synthetic("0123456789abcdef", "")))
Expect(item.ImageBlurHashes).To(BeNil())
})
It("omits a song's album tag when the album art is known absent", func() {