From 6f8e33d28a33c9a78f79e404f2c1b4904906025c Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 23 Jul 2026 20:14:40 -0400 Subject: [PATCH] feat(jellyfin): version album and artist image tags by content hash --- server/jellyfin/dto/mappers.go | 34 +++++++++++++++++--- server/jellyfin/dto/mappers_test.go | 50 +++++++++++++++++++++++++++-- 2 files changed, 77 insertions(+), 7 deletions(-) diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index 444a25cac..16f201790 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -200,7 +200,25 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { return item } +// primaryImageTag returns the image cache tag plus the real-blurhash map; absent art yields ("", nil), +// and unresolved art falls back to the id. A blurhash is never synthesized: Finamp keys its cover cache +// on the value, so a fake one pins a stale cover forever (#5798). +func primaryImageTag(img model.ItemImage, fallback string) (string, map[string]map[string]string) { + if img.ImageAbsent { + return "", nil + } + tag := img.ImageHash + if tag == "" { + tag = fallback + } + if img.BlurHash == "" { + return tag, nil + } + return tag, map[string]map[string]string{"Primary": {tag: img.BlurHash}} +} + func AlbumToBaseItem(al model.Album, fields Fields) BaseItemDto { + tag, blurs := primaryImageTag(al.ItemImage, al.ID) item := BaseItemDto{ Name: al.Name, Id: EncodeID(al.ID), @@ -213,11 +231,13 @@ func AlbumToBaseItem(al model.Album, fields Fields) BaseItemDto { SongCount: new(al.SongCount), 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: blurs, BackdropImageTags: []string{}, UserData: UserData(al.Annotations, al.ID), } + if tag != "" { + item.ImageTags = map[string]string{"Primary": tag} + } if al.AlbumArtistID != "" { item.AlbumArtists = []NameGuidPair{{Name: al.AlbumArtist, Id: EncodeID(al.AlbumArtistID)}} item.ArtistItems = item.AlbumArtists @@ -247,7 +267,8 @@ func AlbumToBaseItem(al model.Album, fields Fields) BaseItemDto { } func ArtistToBaseItem(ar model.Artist) BaseItemDto { - return BaseItemDto{ + tag, blurs := primaryImageTag(ar.ItemImage, ar.ID) + item := BaseItemDto{ Name: ar.Name, Id: EncodeID(ar.ID), Type: "MusicArtist", @@ -255,11 +276,14 @@ func ArtistToBaseItem(ar model.Artist) BaseItemDto { AlbumCount: new(ar.AlbumCount), 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: blurs, BackdropImageTags: []string{}, UserData: UserData(ar.Annotations, ar.ID), } + if tag != "" { + item.ImageTags = map[string]string{"Primary": tag} + } + return item } func GenreToBaseItem(g model.Genre) BaseItemDto { diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index c11f38eaf..e3ed0b9cb 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -254,8 +254,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)) + Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "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"}})) }) @@ -384,6 +384,52 @@ var _ = Describe("mappers", func() { p := model.Playlist{ID: "pl-1", UpdatedAt: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)} Expect(PlaylistToBaseItem(p).ImageTags).To(Equal(PlaylistToBaseItem(p).ImageTags)) }) + + Describe("primary image tags", func() { + It("uses the content hash as the tag and emits the real blurhash", func() { + al := model.Album{ID: "alb-1", Name: "Album"} + al.ImageHash = "0123456789abcdef" + al.BlurHash = "LEHV6nWB2yk8" + + item := AlbumToBaseItem(al, nil) + Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "0123456789abcdef")) + Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("0123456789abcdef", "LEHV6nWB2yk8")) + }) + + 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).To(BeNil(), "a synthesized blurhash pins stale covers in Finamp") + }) + + It("omits tags for known-absent artwork", func() { + al := model.Album{ID: "alb-3", Name: "Album"} + al.ImageAbsent = true + + item := AlbumToBaseItem(al, nil) + Expect(item.ImageTags).To(BeEmpty()) + Expect(item.ImageBlurHashes).To(BeNil()) + }) + + 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).To(BeNil()) + }) + + It("versions an artist's tag by content hash", func() { + ar := model.Artist{ID: "art-1", Name: "Artist"} + ar.ImageHash = "fedcba9876543210" + ar.BlurHash = "L6PZfSi_.AyE" + + item := ArtistToBaseItem(ar) + Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "fedcba9876543210")) + Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("fedcba9876543210", "L6PZfSi_.AyE")) + }) + }) }) var _ = Describe("LyricDtoFromLyrics", func() {