fix(artwork): persist the image freshness signal to stop per-serve recomputes

When a cover file's mtime is newer than the entity row (common), persisting
the row version made the freshness check fail on every serve, re-encoding
and re-writing the same hash each time. The snapshot now stores the newest
signal (row version or image mtime), and both freshness checks accept a
snapshot at-or-after the row version.
This commit is contained in:
Deluan 2026-07-17 11:59:24 -04:00
parent b040345fb0
commit 43500c0ffe
4 changed files with 16 additions and 7 deletions

View File

@ -148,7 +148,9 @@ func (u *blurHashUpdater) process(ctx context.Context, artID model.ArtworkID, re
sig = req.imageUpdatedAt
}
if !req.force {
if stored != "" && storedAt != nil && storedAt.Equal(version) && !sig.After(*storedAt) {
// Current when computed from this row version or later; the snapshot may exceed the row
// version because file mtimes (which don't move rows) are folded into it on persist.
if stored != "" && storedAt != nil && !storedAt.Before(version) && !sig.After(*storedAt) {
return
}
if last, ok := u.lastNoResult(artID); ok && !sig.After(last) {
@ -168,7 +170,7 @@ func (u *blurHashUpdater) process(ctx context.Context, artID model.ArtworkID, re
u.setNoResult(artID, sig)
return
}
if err := u.persist(ctx, artID, hash, version); err != nil {
if err := u.persist(ctx, artID, hash, sig); err != nil {
log.Warn(ctx, "BlurHash: error persisting", "artID", artID, err)
return
}

View File

@ -29,8 +29,9 @@ var _ = Describe("BlurHash", func() {
g.Expect(err).ToNot(HaveOccurred())
g.Expect(len(updated.BlurHash)).To(BeNumerically(">", 6))
g.Expect(updated.BlurHashUpdatedAt).ToNot(BeNil())
// The snapshot must match the artwork version, or the DTO layer would treat it as stale.
g.Expect(updated.BlurHashUpdatedAt.Equal(updated.ArtworkUpdatedAt())).To(BeTrue())
// The snapshot must not be before the artwork version, or the DTO would treat it as
// stale (it may exceed it: image file mtimes are folded in).
g.Expect(updated.BlurHashUpdatedAt.Before(updated.ArtworkUpdatedAt())).To(BeFalse())
}, "10s", "100ms").Should(Succeed())
})

View File

@ -40,10 +40,11 @@ func blurHash(seed string) string {
}
// primaryBlurHash returns the stored blurhash when it was computed from the entity's current
// artwork version; otherwise a fake seeded by id+version, so the value still rotates on any
// artwork change (Finamp keys its cover caches by this value; tags never reach its image URLs).
// artwork version or later (the snapshot folds in image file mtimes, which can exceed row
// timestamps); otherwise a fake seeded by id+version, so the value still rotates on any artwork
// change (Finamp keys its cover caches by this value; tags never reach its image URLs).
func primaryBlurHash(stored string, storedAt *time.Time, id string, version time.Time) string {
if stored != "" && storedAt != nil && storedAt.Equal(version) {
if stored != "" && storedAt != nil && !storedAt.Before(version) {
return stored
}
return blurHash(fmt.Sprintf("%s-%x", id, version.UnixMilli()))

View File

@ -34,6 +34,11 @@ var _ = Describe("primaryBlurHash", func() {
Expect(primaryBlurHash("LEHV6nWB2yk8", &version, "id-1", version)).To(Equal("LEHV6nWB2yk8"))
})
It("returns the stored hash when the snapshot is newer than the version (image mtime)", func() {
newer := version.Add(time.Hour)
Expect(primaryBlurHash("LEHV6nWB2yk8", &newer, "id-1", version)).To(Equal("LEHV6nWB2yk8"))
})
It("falls back to a fake when there is no stored hash", func() {
h := primaryBlurHash("", nil, "id-1", version)
Expect(h).To(HaveLen(6))