diff --git a/core/artwork/blurhash/blurhash.go b/core/artwork/blurhash/blurhash.go index 28d3409f3..ca5bf7026 100644 --- a/core/artwork/blurhash/blurhash.go +++ b/core/artwork/blurhash/blurhash.go @@ -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. diff --git a/core/artwork/blurhash/synthetic.go b/core/artwork/blurhash/synthetic.go deleted file mode 100644 index 1e15507d8..000000000 --- a/core/artwork/blurhash/synthetic.go +++ /dev/null @@ -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)) } diff --git a/core/artwork/blurhash/synthetic_test.go b/core/artwork/blurhash/synthetic_test.go deleted file mode 100644 index 671c1cd30..000000000 --- a/core/artwork/blurhash/synthetic_test.go +++ /dev/null @@ -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)) - }) -}) diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md index b07aaee8d..13ec79aa8 100644 --- a/server/jellyfin/README.md +++ b/server/jellyfin/README.md @@ -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 diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index d2bdb1b2e..31cdba1dd 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -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() diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index 451e1e08b..77b78e55f 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -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() {