fix(jellyfin): emit per-image ImageBlurHashes so clients de-dupe images and stop warning

This commit is contained in:
Deluan 2026-07-05 22:35:01 -04:00
commit f80ee0e41b
5 changed files with 87 additions and 5 deletions

View file

@ -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)
}

View file

@ -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")))
})
})

View file

@ -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

View file

@ -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),
}

View file

@ -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() {