From 77e6f7fdc3e04d9952818a57604c42ce4fb07bf3 Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 23 Jul 2026 23:53:09 -0400 Subject: [PATCH] feat(jellyfin): emit a song's own cover art when it differs from the album's Real Jellyfin fills ImageTags from each item's own images before falling back to the parent album, and Finamp checks imageTags.Primary before AlbumId. Our mapper read only the album's image, so a track with distinct embedded art silently showed the album cover. Emit exactly one entry under ImageBlurHashes.Primary: Go marshals map[string]string in sorted key order rather than insertion order, so a second entry could pair the wrong blurhash with the image imageId resolves to, and Finamp pins that pairing in its cache for 365 days. --- persistence/artwork_hydration_test.go | 18 ++++++++++++ persistence/mediafile_repository.go | 2 ++ server/jellyfin/dto/mappers.go | 11 +++++-- server/jellyfin/dto/mappers_test.go | 41 +++++++++++++++++++++++++++ 4 files changed, 69 insertions(+), 3 deletions(-) diff --git a/persistence/artwork_hydration_test.go b/persistence/artwork_hydration_test.go index b31358bcb..6511110da 100644 --- a/persistence/artwork_hydration_test.go +++ b/persistence/artwork_hydration_test.go @@ -286,6 +286,24 @@ var _ = Describe("Artwork hydration", func() { Expect(byID["2002"].AlbumImage.ImageAbsent).To(BeTrue()) }) + It("carries the blurhash alongside the hash in both the own-art and inherited branches", func() { + setCover("1001", true) // eligible, resolves its own art -> own-art-wins branch + DeferCleanup(func() { setCover("1001", false) }) + + Expect(aw.PutImage(&model.Artwork{Hash: "mfh1001blurxxxxx", Mime: "image/jpeg", BlurHash: "LTRACKblur"})).To(Succeed()) + Expect(aw.PutImage(&model.Artwork{Hash: "alh102blurxxxxxx", Mime: "image/jpeg", BlurHash: "LALBUMblur"})).To(Succeed()) + putInfo("mf", "1001", "mfh1001blurxxxxx") + putInfo("al", "102", "alh102blurxxxxxx") // 1002's album: single-disc inheritance branch + + byID := getByID() + + Expect(byID["1001"].ImageHash).To(Equal("mfh1001blurxxxxx")) + Expect(byID["1001"].BlurHash).To(Equal("LTRACKblur")) + + Expect(byID["1002"].ImageHash).To(Equal("alh102blurxxxxxx")) + Expect(byID["1002"].BlurHash).To(Equal("LALBUMblur")) + }) + It("keeps an eligible file optimistic when its own art is unresolved, even if the album is absent", func() { // 1004 is eligible (has embedded cover) with no mf state row yet; its album (103) is absent. setCover("1004", true) diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index 0685dd833..91110d132 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -246,6 +246,7 @@ func (r *mediaFileRepository) hydrateArtwork(mfs model.MediaFiles) { ownInfo, ownResolved := mfInfos[mf.ID] if eligible && ownResolved && !ownInfo.Absent() { mf.ImageHash = ownInfo.Hash // own resolved art wins + mf.BlurHash = ownInfo.BlurHash continue } // Fallback (see MediaFile.CoverArtID): inherit a found album hash for optimistic caching, @@ -255,6 +256,7 @@ func (r *mediaFileRepository) hydrateArtwork(mfs model.MediaFiles) { if album, ok := albumInfos[mf.AlbumID]; ok && !album.Absent() { if mf.DiscNumber == 0 { mf.ImageHash = album.Hash + mf.BlurHash = album.BlurHash } continue } diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index eda1fd1df..e78eb0e81 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -192,9 +192,14 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { } else if mf.Genre != "" { item.Genres = []string{mf.Genre} } - // Finamp resolves song art via AlbumId + a non-empty AlbumPrimaryImageTag, so this tag - // must version the ALBUM's image, not the track's own art. - if mf.AlbumID != "" { + // A track's own cover wins in Finamp's precedence (ImageTags.Primary before AlbumId); emit only + // the matching blurhash, since Go sorts map keys and a second entry could pair the wrong one. + if mf.ImageHash != "" && mf.ImageHash != mf.AlbumImage.ImageHash { + item.ImageTags = map[string]string{"Primary": mf.ImageHash} + if mf.BlurHash != "" { + item.ImageBlurHashes = map[string]map[string]string{"Primary": {mf.ImageHash: mf.BlurHash}} + } + } else if mf.AlbumID != "" { if tag, blurs := primaryImageTag(mf.AlbumImage, mf.AlbumID); tag != "" { item.AlbumPrimaryImageTag = tag item.ImageBlurHashes = blurs diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index bc34df19e..2b80a42d6 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -471,6 +471,47 @@ var _ = Describe("mappers", func() { Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "abcdef0123456789")) }) }) + + Describe("per-song artwork", func() { + It("emits the track's own Primary tag when it has distinct art", func() { + mf := model.MediaFile{ID: "song-own", Title: "Song", AlbumID: "alb-1"} + mf.ImageHash = "aaaaaaaaaaaaaaaa" + mf.BlurHash = "LTRACKblur" + mf.AlbumImage.ImageHash = "bbbbbbbbbbbbbbbb" + mf.AlbumImage.BlurHash = "LALBUMblur" + + item := SongToBaseItem(mf, nil) + Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "aaaaaaaaaaaaaaaa")) + Expect(item.ImageBlurHashes["Primary"]).To(HaveLen(1), + "exactly one Primary entry: Go sorts map keys, so a second entry could pair the wrong blurhash with imageId") + Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("aaaaaaaaaaaaaaaa", "LTRACKblur")) + }) + + It("falls back to the album tag when the track has no distinct art", func() { + mf := model.MediaFile{ID: "song-inherit", Title: "Song", AlbumID: "alb-1"} + mf.ImageHash = "bbbbbbbbbbbbbbbb" + mf.BlurHash = "LALBUMblur" + mf.AlbumImage.ImageHash = "bbbbbbbbbbbbbbbb" + mf.AlbumImage.BlurHash = "LALBUMblur" + + item := SongToBaseItem(mf, nil) + Expect(item.ImageTags).To(BeEmpty(), "an inherited cover is the album's image, not the track's") + Expect(item.AlbumPrimaryImageTag).To(Equal("bbbbbbbbbbbbbbbb")) + Expect(item.ImageBlurHashes["Primary"]).To(HaveLen(1)) + Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("bbbbbbbbbbbbbbbb", "LALBUMblur")) + }) + + It("omits the track tag when its own art is known absent", func() { + mf := model.MediaFile{ID: "song-absent", Title: "Song", AlbumID: "alb-1"} + mf.ImageAbsent = true + mf.AlbumImage.ImageAbsent = true + + item := SongToBaseItem(mf, nil) + Expect(item.ImageTags).To(BeEmpty()) + Expect(item.AlbumPrimaryImageTag).To(BeEmpty()) + Expect(item.ImageBlurHashes).To(BeNil()) + }) + }) }) var _ = Describe("LyricDtoFromLyrics", func() {