From 82b9538bfff1b4d21d9c401b0cd2a70b6f377a91 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 31 Jul 2026 16:27:15 -0400 Subject: [PATCH] feat(artwork): hydrate and expose thumbHash on every entity ItemArtworkInfo.Image() is the single projection every hydration branch goes through, so adding the field there covers albums, artists, playlists and both the own-art and inherited media-file branches. --- core/artwork/blurhash/blurhash_bench_test.go | 3 +-- model/artwork.go | 13 ++++++++----- model/artwork_test.go | 17 +++++++++++++++++ persistence/artwork_hydration_test.go | 12 ++++++++---- persistence/artwork_repository.go | 1 + 5 files changed, 35 insertions(+), 11 deletions(-) diff --git a/core/artwork/blurhash/blurhash_bench_test.go b/core/artwork/blurhash/blurhash_bench_test.go index 9e393d58f..9347917d7 100644 --- a/core/artwork/blurhash/blurhash_bench_test.go +++ b/core/artwork/blurhash/blurhash_bench_test.go @@ -40,10 +40,9 @@ func BenchmarkEncodeAtInputSize(b *testing.B) { }) } } - x, y := blurhash.Components(size, size) b.ReportAllocs() for range b.N { - if _, err := blurhash.Encode(img, x, y); err != nil { + if _, err := blurhash.Encode(img); err != nil { b.Fatal(err) } } diff --git a/model/artwork.go b/model/artwork.go index 0fb2c9dbd..37e13dbd8 100644 --- a/model/artwork.go +++ b/model/artwork.go @@ -21,6 +21,7 @@ type ItemImage struct { ImageHash string `structs:"-" json:"imageHash,omitempty"` ImageAbsent bool `structs:"-" json:"imageAbsent,omitempty"` BlurHash string `structs:"-" json:"blurHash,omitempty"` + ThumbHash string `structs:"-" json:"thumbHash,omitempty"` // A blurhash carries no aspect ratio, so clients need these to shape the placeholder. ImageWidth int `structs:"-" json:"imageWidth,omitempty"` ImageHeight int `structs:"-" json:"imageHeight,omitempty"` @@ -52,11 +53,12 @@ type ItemArtwork struct { // ItemArtworkInfo is the list-hydration projection (item_artwork joined with artwork). type ItemArtworkInfo struct { - ItemID string - Hash string - BlurHash string - Width int - Height int + ItemID string + Hash string + BlurHash string + ThumbHash string + Width int + Height int } // Absent reports a known-absent artwork state (resolved, no image). @@ -68,6 +70,7 @@ func (i ItemArtworkInfo) Image() ItemImage { ImageHash: i.Hash, ImageAbsent: i.Absent(), BlurHash: i.BlurHash, + ThumbHash: i.ThumbHash, ImageWidth: i.Width, ImageHeight: i.Height, } diff --git a/model/artwork_test.go b/model/artwork_test.go index a6a3fc701..2c755e095 100644 --- a/model/artwork_test.go +++ b/model/artwork_test.go @@ -23,6 +23,23 @@ var _ = Describe("ItemImage JSON", func() { Expect(out).To(HaveKeyWithValue("blurHash", "LEHV6nWB2yk8")) }) + It("exposes the thumbhash, and omits it when the entity has none", func() { + al := model.Album{ID: "al-4", Name: "Album"} + al.ThumbHash = "1QcSHQRn" + + var out map[string]any + data, err := json.Marshal(al) + Expect(err).ToNot(HaveOccurred()) + Expect(json.Unmarshal(data, &out)).To(Succeed()) + Expect(out).To(HaveKeyWithValue("thumbHash", "1QcSHQRn")) + + var bare map[string]any + data, err = json.Marshal(model.Album{ID: "al-5", Name: "Album"}) + Expect(err).ToNot(HaveOccurred()) + Expect(json.Unmarshal(data, &bare)).To(Succeed()) + Expect(bare).ToNot(HaveKey("thumbHash")) + }) + // Without the dimensions, clients cannot know the placeholder's shape and default to a square. It("exposes the image dimensions alongside the blurhash", func() { al := model.Album{ID: "al-3", Name: "Album"} diff --git a/persistence/artwork_hydration_test.go b/persistence/artwork_hydration_test.go index 6948dfd30..2b52d7d82 100644 --- a/persistence/artwork_hydration_test.go +++ b/persistence/artwork_hydration_test.go @@ -289,12 +289,12 @@ var _ = Describe("Artwork hydration", func() { Expect(byID["2002"].AlbumImage.ImageAbsent).To(BeTrue()) }) - It("carries the blurhash and its dimensions alongside the hash in both the own-art and inherited branches", func() { + It("carries both hashes and the dimensions alongside the hash in 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", Width: 640, Height: 480})).To(Succeed()) - Expect(aw.PutImage(&model.Artwork{Hash: "alh102blurxxxxxx", Mime: "image/jpeg", BlurHash: "LALBUMblur", Width: 1200, Height: 800})).To(Succeed()) + Expect(aw.PutImage(&model.Artwork{Hash: "mfh1001blurxxxxx", Mime: "image/jpeg", BlurHash: "LTRACKblur", ThumbHash: "THtrack", Width: 640, Height: 480})).To(Succeed()) + Expect(aw.PutImage(&model.Artwork{Hash: "alh102blurxxxxxx", Mime: "image/jpeg", BlurHash: "LALBUMblur", ThumbHash: "THalbum", Width: 1200, Height: 800})).To(Succeed()) putInfo("mf", "1001", "mfh1001blurxxxxx") putInfo("al", "102", "alh102blurxxxxxx") // 1002's album: single-disc inheritance branch @@ -302,11 +302,13 @@ var _ = Describe("Artwork hydration", func() { Expect(byID["1001"].ImageHash).To(Equal("mfh1001blurxxxxx")) Expect(byID["1001"].BlurHash).To(Equal("LTRACKblur")) + Expect(byID["1001"].ThumbHash).To(Equal("THtrack")) Expect(byID["1001"].ImageWidth).To(Equal(640)) Expect(byID["1001"].ImageHeight).To(Equal(480)) Expect(byID["1002"].ImageHash).To(Equal("alh102blurxxxxxx")) Expect(byID["1002"].BlurHash).To(Equal("LALBUMblur")) + Expect(byID["1002"].ThumbHash).To(Equal("THalbum")) Expect(byID["1002"].ImageWidth).To(Equal(1200)) Expect(byID["1002"].ImageHeight).To(Equal(800)) }) @@ -739,13 +741,14 @@ var _ = Describe("Artwork hydration", func() { Describe("applyItemImage", func() { It("copies hash, absence, blurhash and dimensions onto the item", func() { infos := map[string]model.ItemArtworkInfo{ - "al-1": {ItemID: "al-1", Hash: "0123456789abcdef", BlurHash: "LEHV6nWB2yk8", Width: 1200, Height: 800}, + "al-1": {ItemID: "al-1", Hash: "0123456789abcdef", BlurHash: "LEHV6nWB2yk8", ThumbHash: "1QcSHQRn", Width: 1200, Height: 800}, } var img model.ItemImage applyItemImage(infos, "al-1", &img) Expect(img.ImageHash).To(Equal("0123456789abcdef")) Expect(img.ImageAbsent).To(BeFalse()) Expect(img.BlurHash).To(Equal("LEHV6nWB2yk8")) + Expect(img.ThumbHash).To(Equal("1QcSHQRn")) Expect(img.ImageWidth).To(Equal(1200)) Expect(img.ImageHeight).To(Equal(800)) }) @@ -756,6 +759,7 @@ var _ = Describe("Artwork hydration", func() { applyItemImage(infos, "al-2", &img) Expect(img.ImageAbsent).To(BeTrue()) Expect(img.BlurHash).To(BeEmpty()) + Expect(img.ThumbHash).To(BeEmpty()) }) It("leaves an unresolved item zero-valued", func() { diff --git a/persistence/artwork_repository.go b/persistence/artwork_repository.go index ba5e245ec..238513c04 100644 --- a/persistence/artwork_repository.go +++ b/persistence/artwork_repository.go @@ -191,6 +191,7 @@ func (r *artworkRepository) GetInfoForItems(kind model.Kind, ids []string) (map[ res := map[string]model.ItemArtworkInfo{} for chunk := range slices.Chunk(ids, artworkBatchSize) { sel := Select("ia.item_id", "ia.hash", "COALESCE(a.blur_hash, '') as blur_hash", + "COALESCE(a.thumb_hash, '') as thumb_hash", "COALESCE(a.width, 0) as width", "COALESCE(a.height, 0) as height"). From(itemArtworkTable + " ia"). LeftJoin("artwork a ON a.hash = ia.hash").