From 313e93c3d9a7843eec81745d4297082396eb3ae9 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 31 Jul 2026 16:24:47 -0400 Subject: [PATCH] feat(artwork): persist a thumbhash alongside the blurhash The artwork table is content-addressed, so this costs one row per unique image rather than per entity. Measured on a 25k-image library: +0.6MB on a 727MB DB. The column is added to the existing add_artwork_tables migration rather than a new one, since #5847 has not been released and the table it extends ships in that same PR. --- .../20260722023032_add_artwork_tables.sql | 1 + model/artwork.go | 1 + persistence/artwork_repository.go | 3 ++- persistence/artwork_repository_test.go | 19 +++++++++++++++++++ 4 files changed, 23 insertions(+), 1 deletion(-) diff --git a/db/migrations/20260722023032_add_artwork_tables.sql b/db/migrations/20260722023032_add_artwork_tables.sql index a7bd0897c..a4935b692 100644 --- a/db/migrations/20260722023032_add_artwork_tables.sql +++ b/db/migrations/20260722023032_add_artwork_tables.sql @@ -6,6 +6,7 @@ CREATE TABLE artwork ( height INTEGER NOT NULL DEFAULT 0, size_bytes INTEGER NOT NULL DEFAULT 0, blur_hash TEXT NOT NULL DEFAULT '', + thumb_hash TEXT NOT NULL DEFAULT '', created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); diff --git a/model/artwork.go b/model/artwork.go index 458888d23..0fb2c9dbd 100644 --- a/model/artwork.go +++ b/model/artwork.go @@ -10,6 +10,7 @@ type Artwork struct { Height int `structs:"height"` SizeBytes int64 `structs:"size_bytes"` BlurHash string `structs:"blur_hash"` + ThumbHash string `structs:"thumb_hash"` CreatedAt time.Time `structs:"created_at"` } diff --git a/persistence/artwork_repository.go b/persistence/artwork_repository.go index 0d98406ee..ba5e245ec 100644 --- a/persistence/artwork_repository.go +++ b/persistence/artwork_repository.go @@ -54,7 +54,8 @@ func (r *artworkRepository) PutImage(a *model.Artwork) error { } // created_at=excluded.created_at: reacquiring an orphan must reset the prune grace window. ins := Insert(r.tableName).SetMap(values).Suffix(`ON CONFLICT (hash) DO UPDATE SET mime=excluded.mime, width=excluded.width, - height=excluded.height, size_bytes=excluded.size_bytes, blur_hash=excluded.blur_hash, created_at=excluded.created_at`) + height=excluded.height, size_bytes=excluded.size_bytes, blur_hash=excluded.blur_hash, + thumb_hash=excluded.thumb_hash, created_at=excluded.created_at`) _, err = r.executeSQL(ins) return err } diff --git a/persistence/artwork_repository_test.go b/persistence/artwork_repository_test.go index 3910a5575..df8c10dfc 100644 --- a/persistence/artwork_repository_test.go +++ b/persistence/artwork_repository_test.go @@ -40,6 +40,25 @@ var _ = Describe("ArtworkRepository", func() { Expect(got.CreatedAt).ToNot(BeZero()) }) + It("round-trips the thumbhash alongside the blurhash", func() { + a := &model.Artwork{Hash: "both1", Mime: "image/jpeg", BlurHash: "LKO2?U%2Tw=w", ThumbHash: "1QcSHQRnh493V4dIh4eXh1h4kJUI"} + Expect(repo.PutImage(a)).To(Succeed()) + + got, err := repo.GetImage("both1") + Expect(err).ToNot(HaveOccurred()) + Expect(got.BlurHash).To(Equal("LKO2?U%2Tw=w")) + Expect(got.ThumbHash).To(Equal("1QcSHQRnh493V4dIh4eXh1h4kJUI")) + }) + + It("overwrites the thumbhash on re-acquisition", func() { + Expect(repo.PutImage(&model.Artwork{Hash: "th2", Mime: "image/png", ThumbHash: "first"})).To(Succeed()) + Expect(repo.PutImage(&model.Artwork{Hash: "th2", Mime: "image/png", ThumbHash: "second"})).To(Succeed()) + + got, err := repo.GetImage("th2") + Expect(err).ToNot(HaveOccurred()) + Expect(got.ThumbHash).To(Equal("second")) + }) + It("is idempotent on Put (upsert by hash)", func() { a := &model.Artwork{Hash: "dup1", Mime: "image/png"} Expect(repo.PutImage(a)).To(Succeed())