diff --git a/server/jellyfin/dto/blurhash.go b/server/jellyfin/dto/blurhash.go index aaf6ff2af..cc846bb81 100644 --- a/server/jellyfin/dto/blurhash.go +++ b/server/jellyfin/dto/blurhash.go @@ -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())) +} diff --git a/server/jellyfin/dto/blurhash_test.go b/server/jellyfin/dto/blurhash_test.go index a6e36131d..78cd242f8 100644 --- a/server/jellyfin/dto/blurhash_test.go +++ b/server/jellyfin/dto/blurhash_test.go @@ -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)) + }) +}) diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index c65b8933d..e284ad0de 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -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), } diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index e72efd617..fbd3b16bd 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -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")) + }) +})