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.
This commit is contained in:
Deluan 2026-07-23 23:53:09 -04:00
parent 8545cc4762
commit 77e6f7fdc3
4 changed files with 69 additions and 3 deletions

View File

@ -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)

View File

@ -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
}

View File

@ -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

View File

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