From f80ee0e41b40f7aa5e7267e3cea5bdbfdfe0d7ef Mon Sep 17 00:00:00 2001 From: Deluan Date: Sun, 5 Jul 2026 22:35:01 -0400 Subject: [PATCH] fix(jellyfin): emit per-image ImageBlurHashes so clients de-dupe images and stop warning --- server/jellyfin/dto/blurhash.go | 37 ++++++++++++++++++++++++++++ server/jellyfin/dto/blurhash_test.go | 27 ++++++++++++++++++++ server/jellyfin/dto/dto.go | 14 +++++++---- server/jellyfin/dto/mappers.go | 3 +++ server/jellyfin/dto/mappers_test.go | 11 +++++++++ 5 files changed, 87 insertions(+), 5 deletions(-) create mode 100644 server/jellyfin/dto/blurhash.go create mode 100644 server/jellyfin/dto/blurhash_test.go diff --git a/server/jellyfin/dto/blurhash.go b/server/jellyfin/dto/blurhash.go new file mode 100644 index 000000000..c42af9cbf --- /dev/null +++ b/server/jellyfin/dto/blurhash.go @@ -0,0 +1,37 @@ +package dto + +import "hash/fnv" + +// base83Alphabet is the blurhash spec's base83 encoding alphabet; order is part of the spec. +const base83Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~" + +// base83 encodes value as a fixed-width, big-endian base83 string of the given length. +func base83(value, length int) string { + b := make([]byte, length) + for i := 1; i <= length; i++ { + digit := (value / pow83(length-i)) % 83 + b[i-1] = base83Alphabet[digit] + } + return string(b) +} + +func pow83(n int) int { + result := 1 + for range n { + result *= 83 + } + return result +} + +// blurHash returns a valid 6-char blurhash encoding a solid color deterministically derived +// from seed. We can't afford to compute real blurhashes from cover art, but Finamp only needs a +// well-formed, per-image-tag-stable value: it uses this as a de-dup key for image downloads and +// a blur placeholder, so a solid color unique to the tag satisfies both without decoding images. +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) +} diff --git a/server/jellyfin/dto/blurhash_test.go b/server/jellyfin/dto/blurhash_test.go new file mode 100644 index 000000000..a6e36131d --- /dev/null +++ b/server/jellyfin/dto/blurhash_test.go @@ -0,0 +1,27 @@ +package dto + +import ( + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("blurHash", func() { + It("returns a 6-char valid blurhash starting with the 1x1 component prefix", func() { + h := blurHash("x") + Expect(h).To(HaveLen(6)) + Expect(h).To(HavePrefix("00")) + for _, c := range h { + Expect(strings.ContainsRune(base83Alphabet, c)).To(BeTrue(), "unexpected char %q", c) + } + }) + + It("is deterministic for the same seed", func() { + Expect(blurHash("cover-tag-1")).To(Equal(blurHash("cover-tag-1"))) + }) + + It("differs for different seeds", func() { + Expect(blurHash("cover-tag-1")).ToNot(Equal(blurHash("cover-tag-2"))) + }) +}) diff --git a/server/jellyfin/dto/dto.go b/server/jellyfin/dto/dto.go index 4fd9bfdf2..a3cc3eaa3 100644 --- a/server/jellyfin/dto/dto.go +++ b/server/jellyfin/dto/dto.go @@ -65,11 +65,15 @@ type BaseItemDto struct { SongCount *int `json:"SongCount,omitempty"` AlbumCount *int `json:"AlbumCount,omitempty"` ImageTags map[string]string `json:"ImageTags,omitempty"` - BackdropImageTags []string `json:"BackdropImageTags"` - UserData *UserItemDataDto `json:"UserData,omitempty"` - MediaSources []MediaSourceInfo `json:"MediaSources,omitempty"` - Container string `json:"Container,omitempty"` - CanDownload bool `json:"CanDownload"` + // ImageBlurHashes is keyed by image type (e.g. "Primary") then image tag. Finamp uses it both + // to render a placeholder while art loads and, more importantly, as a de-dup key for image + // downloads -- without it Finamp warns that the server isn't calculating blurhashes. + ImageBlurHashes map[string]map[string]string `json:"ImageBlurHashes,omitempty"` + BackdropImageTags []string `json:"BackdropImageTags"` + UserData *UserItemDataDto `json:"UserData,omitempty"` + MediaSources []MediaSourceInfo `json:"MediaSources,omitempty"` + Container string `json:"Container,omitempty"` + CanDownload bool `json:"CanDownload"` } // PlaylistUserPermissions is the response shape for GET /Playlists/{id}/Users(/{userId}), which diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index 1c04c834c..377d7a203 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -117,6 +117,7 @@ func SongToBaseItem(mf model.MediaFile) BaseItemDto { // Finamp resolves song art via AlbumId + a non-empty AlbumPrimaryImageTag. if mf.AlbumID != "" { item.AlbumPrimaryImageTag = mf.AlbumID + item.ImageBlurHashes = map[string]map[string]string{"Primary": {mf.AlbumID: blurHash(mf.AlbumID)}} } return item } @@ -134,6 +135,7 @@ func AlbumToBaseItem(al model.Album) BaseItemDto { SongCount: new(al.SongCount), RunTimeTicks: TicksFromSeconds(al.Duration), ImageTags: map[string]string{"Primary": al.ID}, + ImageBlurHashes: map[string]map[string]string{"Primary": {al.ID: blurHash(al.ID)}}, BackdropImageTags: []string{}, UserData: UserData(al.Annotations, al.ID), } @@ -161,6 +163,7 @@ func ArtistToBaseItem(ar model.Artist) BaseItemDto { AlbumCount: new(ar.AlbumCount), SongCount: new(ar.SongCount), ImageTags: map[string]string{"Primary": ar.ID}, + ImageBlurHashes: map[string]map[string]string{"Primary": {ar.ID: blurHash(ar.ID)}}, BackdropImageTags: []string{}, UserData: UserData(ar.Annotations, ar.ID), } diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index b7e314548..e8ebf93c7 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -33,6 +33,15 @@ var _ = Describe("mappers", func() { Expect(item.UserData.ItemId).To(Equal(EncodeID("song-1"))) Expect(item.MediaSources).To(HaveLen(1)) Expect(item.MediaSources[0].Size).To(Equal(int64(2_500_000))) + Expect(item.ImageBlurHashes["Primary"]).To(HaveKey(item.AlbumPrimaryImageTag)) + Expect(item.ImageBlurHashes["Primary"][item.AlbumPrimaryImageTag]).To(HaveLen(6)) + }) + + It("omits ImageBlurHashes when a song has no album", func() { + mf := model.MediaFile{ID: "song-noalbum", Title: "Song", Duration: 60} + item := SongToBaseItem(mf) + Expect(item.AlbumPrimaryImageTag).To(BeEmpty()) + Expect(item.ImageBlurHashes).To(BeNil()) }) It("builds a MediaSourceInfo from a media file", func() { @@ -125,6 +134,8 @@ var _ = Describe("mappers", func() { Expect(item.ArtistItems).To(Equal(item.AlbumArtists)) Expect(*item.ProductionYear).To(Equal(1999)) Expect(*item.ChildCount).To(Equal(10)) + Expect(item.ImageBlurHashes["Primary"]).To(HaveKey(item.ImageTags["Primary"])) + Expect(item.ImageBlurHashes["Primary"][item.ImageTags["Primary"]]).To(HaveLen(6)) }) It("maps an artist to a MusicArtist folder item", func() {