feat(jellyfin): emit stored blurhashes with version-seeded fake fallback

This commit is contained in:
Deluan 2026-07-16 00:21:19 -04:00
parent 9f9019df07
commit 585ac0aab3
4 changed files with 76 additions and 4 deletions

View File

@ -1,6 +1,10 @@
package dto
import "hash/fnv"
import (
"fmt"
"hash/fnv"
"time"
)
// base83Alphabet is the blurhash spec's base83 encoding alphabet; order is part of the spec.
const base83Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"
@ -34,3 +38,13 @@ func blurHash(seed string) string {
dc := (r << 16) | (g << 8) | b
return "00" + base83(dc, 4)
}
// primaryBlurHash returns the stored blurhash when it was computed from the entity's current
// artwork version; otherwise a fake seeded by id+version, so the value still rotates on any
// artwork change (Finamp keys its cover caches by this value; tags never reach its image URLs).
func primaryBlurHash(stored string, storedAt *time.Time, id string, version time.Time) string {
if stored != "" && storedAt != nil && storedAt.Equal(version) {
return stored
}
return blurHash(fmt.Sprintf("%s-%x", id, version.UnixMilli()))
}

View File

@ -2,6 +2,7 @@ package dto
import (
"strings"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@ -25,3 +26,29 @@ var _ = Describe("blurHash", func() {
Expect(blurHash("cover-tag-1")).ToNot(Equal(blurHash("cover-tag-2")))
})
})
var _ = Describe("primaryBlurHash", func() {
version := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
It("returns the stored hash when it matches the current artwork version", func() {
Expect(primaryBlurHash("LEHV6nWB2yk8", &version, "id-1", version)).To(Equal("LEHV6nWB2yk8"))
})
It("falls back to a fake when there is no stored hash", func() {
h := primaryBlurHash("", nil, "id-1", version)
Expect(h).To(HaveLen(6))
})
It("falls back to a fake when the stored hash is stale", func() {
stale := version.Add(-time.Hour)
h := primaryBlurHash("LEHV6nWB2yk8", &stale, "id-1", version)
Expect(h).To(HaveLen(6))
Expect(h).ToNot(Equal("LEHV6nWB2yk8"))
})
It("rotates the fake when the artwork version moves", func() {
h1 := primaryBlurHash("", nil, "id-1", version)
h2 := primaryBlurHash("", nil, "id-1", version.Add(time.Hour))
Expect(h1).ToNot(Equal(h2))
})
})

View File

@ -201,7 +201,7 @@ func AlbumToBaseItem(al model.Album) BaseItemDto {
RunTimeTicks: TicksFromSeconds(al.Duration),
DateCreated: jellyfinDate(&al.CreatedAt),
ImageTags: map[string]string{"Primary": al.ID},
ImageBlurHashes: map[string]map[string]string{"Primary": {al.ID: blurHash(al.ID)}},
ImageBlurHashes: map[string]map[string]string{"Primary": {al.ID: primaryBlurHash(al.BlurHash, al.BlurHashUpdatedAt, al.ID, al.ArtworkUpdatedAt())}},
BackdropImageTags: []string{},
UserData: UserData(al.Annotations, al.ID),
}
@ -231,7 +231,7 @@ func ArtistToBaseItem(ar model.Artist) BaseItemDto {
SongCount: new(ar.SongCount),
DateCreated: jellyfinDate(ar.CreatedAt),
ImageTags: map[string]string{"Primary": ar.ID},
ImageBlurHashes: map[string]map[string]string{"Primary": {ar.ID: blurHash(ar.ID)}},
ImageBlurHashes: map[string]map[string]string{"Primary": {ar.ID: primaryBlurHash(ar.BlurHash, ar.BlurHashUpdatedAt, ar.ID, ar.ArtworkUpdatedAt())}},
BackdropImageTags: []string{},
UserData: UserData(ar.Annotations, ar.ID),
}
@ -264,7 +264,7 @@ func PlaylistToBaseItem(p model.Playlist) BaseItemDto {
ChildCount: new(p.SongCount),
RunTimeTicks: TicksFromSeconds(p.Duration),
ImageTags: map[string]string{"Primary": tag},
ImageBlurHashes: map[string]map[string]string{"Primary": {tag: blurHash(tag)}},
ImageBlurHashes: map[string]map[string]string{"Primary": {tag: primaryBlurHash(p.BlurHash, p.BlurHashUpdatedAt, p.ID, p.ArtworkUpdatedAt())}},
BackdropImageTags: []string{},
UserData: UserData(p.Annotations, p.ID),
}

View File

@ -390,3 +390,34 @@ var _ = Describe("LyricDtoFromLyrics", func() {
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 a rotating fake 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
fake := AlbumToBaseItem(al).ImageBlurHashes["Primary"]["al-1"]
Expect(fake).To(HaveLen(6))
al.UpdatedAt = version.Add(2 * time.Hour)
Expect(AlbumToBaseItem(al).ImageBlurHashes["Primary"]["al-1"]).ToNot(Equal(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"))
})
})