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