feat(artwork): implement item_artwork repository with batched hydration

This commit is contained in:
Deluan 2026-07-21 22:45:03 -04:00
parent f926539c04
commit 14dd57052e
2 changed files with 138 additions and 5 deletions

View File

@ -4,6 +4,7 @@ import (
"context"
"time"
. "github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/model"
"github.com/pocketbase/dbx"
)
@ -21,23 +22,72 @@ func NewItemArtworkRepository(ctx context.Context, db dbx.Builder) model.ItemArt
}
func (r *itemArtworkRepository) Get(kind, id, imageType string) (*model.ItemArtwork, error) {
return nil, model.ErrNotFound
sel := Select("*").From(r.tableName).
Where(Eq{"item_kind": kind, "item_id": id, "image_type": imageType})
var res model.ItemArtwork
if err := r.queryOne(sel, &res); err != nil {
return nil, err
}
return &res, nil
}
func (r *itemArtworkRepository) Put(ia *model.ItemArtwork) error {
return nil
if ia.ImageType == "" {
ia.ImageType = model.ImageTypePrimary
}
ia.UpdatedAt = time.Now()
ins := Insert(r.tableName).SetMap(map[string]any{
"item_kind": ia.ItemKind, "item_id": ia.ItemID, "image_type": ia.ImageType,
"hash": ia.Hash, "source": ia.Source,
"attempted_at": ia.AttemptedAt, "updated_at": ia.UpdatedAt,
}).Suffix(`ON CONFLICT (item_kind, item_id, image_type) DO UPDATE SET
hash=excluded.hash, source=excluded.source,
attempted_at=excluded.attempted_at, updated_at=excluded.updated_at`)
_, err := r.executeSQL(ins)
return err
}
func (r *itemArtworkRepository) DeleteForItem(kind, id string) error {
return nil
return r.delete(Eq{"item_kind": kind, "item_id": id})
}
func (r *itemArtworkRepository) GetInfoForItems(kind string, ids []string) (map[string]model.ItemArtworkInfo, error) {
return nil, nil
res := map[string]model.ItemArtworkInfo{}
if len(ids) == 0 {
return res, nil
}
sel := Select("ia.item_id", "ia.hash", "COALESCE(a.blur_hash, '') as blur_hash").
From(r.tableName + " ia").
LeftJoin("artwork a ON a.hash = ia.hash").
Where(And{
Eq{"ia.item_kind": kind},
Eq{"ia.image_type": model.ImageTypePrimary},
Eq{"ia.item_id": ids},
})
var rows []struct {
ItemID string
Hash string
BlurHash string
}
if err := r.queryAll(sel, &rows); err != nil {
return nil, err
}
for _, row := range rows {
res[row.ItemID] = model.ItemArtworkInfo{
ItemID: row.ItemID, Hash: row.Hash, BlurHash: row.BlurHash, Absent: row.Hash == "",
}
}
return res, nil
}
func (r *itemArtworkRepository) EnqueueStaleAbsent(kind string, attemptedBefore time.Time) (int64, error) {
return 0, nil
now := time.Now()
ins := Expr(`INSERT INTO artwork_queue (item_kind, item_id, image_type, priority, attempts, retry_at, enqueued_at)
SELECT item_kind, item_id, image_type, ?, 0, ?, ?
FROM item_artwork WHERE item_kind = ? AND hash = '' AND attempted_at < ?
ON CONFLICT (item_kind, item_id, image_type) DO NOTHING`,
model.ArtworkPriorityRecheck, now, now, kind, attemptedBefore)
return r.executeSQL(ins)
}
var _ model.ItemArtworkRepository = (*itemArtworkRepository)(nil)

View File

@ -0,0 +1,83 @@
package persistence
import (
"context"
"time"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("ItemArtworkRepository", func() {
var repo model.ItemArtworkRepository
var awRepo model.ArtworkRepository
BeforeEach(func() {
clearArtworkTables()
repo = NewItemArtworkRepository(context.Background(), GetDBXBuilder())
awRepo = NewArtworkRepository(context.Background(), GetDBXBuilder())
})
It("upserts and reads state", func() {
ia := &model.ItemArtwork{ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary,
Hash: "h1", Source: "folder", AttemptedAt: time.Now()}
Expect(repo.Put(ia)).To(Succeed())
ia.Source = "embedded"
Expect(repo.Put(ia)).To(Succeed())
got, err := repo.Get("al", "al1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(got.Source).To(Equal("embedded"))
Expect(got.UpdatedAt).ToNot(BeZero())
})
It("represents known-absent as empty hash", func() {
Expect(repo.Put(&model.ItemArtwork{ItemKind: "ar", ItemID: "ar1",
ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()})).To(Succeed())
got, err := repo.Get("ar", "ar1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(got.Hash).To(BeEmpty())
})
It("hydrates a page in one batch, including blurhash and absence", func() {
Expect(awRepo.Put(&model.Artwork{Hash: "h9", Mime: "image/jpeg", BlurHash: "BH9"})).To(Succeed())
Expect(repo.Put(&model.ItemArtwork{ItemKind: "al", ItemID: "x1", ImageType: model.ImageTypePrimary, Hash: "h9", Source: "folder"})).To(Succeed())
Expect(repo.Put(&model.ItemArtwork{ItemKind: "al", ItemID: "x2", ImageType: model.ImageTypePrimary, Hash: "", Source: ""})).To(Succeed())
info, err := repo.GetInfoForItems("al", []string{"x1", "x2", "x3"})
Expect(err).ToNot(HaveOccurred())
Expect(info).To(HaveLen(2))
Expect(info["x1"].Hash).To(Equal("h9"))
Expect(info["x1"].BlurHash).To(Equal("BH9"))
Expect(info["x1"].Absent).To(BeFalse())
Expect(info["x2"].Absent).To(BeTrue())
_, unresolved := info["x3"]
Expect(unresolved).To(BeFalse())
})
It("deletes all rows for an item", func() {
Expect(repo.Put(&model.ItemArtwork{ItemKind: "pl", ItemID: "p1", ImageType: model.ImageTypePrimary, Hash: "h1"})).To(Succeed())
Expect(repo.DeleteForItem("pl", "p1")).To(Succeed())
_, err := repo.Get("pl", "p1", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
})
It("enqueues stale absent states for recheck", func() {
old := time.Now().Add(-48 * time.Hour)
Expect(repo.Put(&model.ItemArtwork{ItemKind: "ar", ItemID: "stale1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old})).To(Succeed())
Expect(repo.Put(&model.ItemArtwork{ItemKind: "ar", ItemID: "fresh1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()})).To(Succeed())
Expect(repo.Put(&model.ItemArtwork{ItemKind: "ar", ItemID: "found1", ImageType: model.ImageTypePrimary, Hash: "hX", AttemptedAt: old})).To(Succeed())
n, err := repo.EnqueueStaleAbsent("ar", time.Now().Add(-24*time.Hour))
Expect(err).ToNot(HaveOccurred())
Expect(n).To(Equal(int64(1)))
qRepo := NewArtworkQueueRepository(context.Background(), GetDBXBuilder())
items, err := qRepo.DequeueBatch(10)
Expect(err).ToNot(HaveOccurred())
Expect(items).To(HaveLen(1))
Expect(items[0].ItemID).To(Equal("stale1"))
Expect(items[0].Priority).To(Equal(model.ArtworkPriorityRecheck))
})
})