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.
This commit is contained in:
Deluan 2026-07-31 16:24:47 -04:00
parent a4d979c49c
commit 313e93c3d9
4 changed files with 23 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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