diff --git a/persistence/item_artwork_repository.go b/persistence/item_artwork_repository.go index 8363264ca..08b4caf3c 100644 --- a/persistence/item_artwork_repository.go +++ b/persistence/item_artwork_repository.go @@ -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) diff --git a/persistence/item_artwork_repository_test.go b/persistence/item_artwork_repository_test.go new file mode 100644 index 000000000..6aec55d89 --- /dev/null +++ b/persistence/item_artwork_repository_test.go @@ -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)) + }) +})