diff --git a/core/artwork/prune.go b/core/artwork/prune.go index 9c88611a3..11d9ce34f 100644 --- a/core/artwork/prune.go +++ b/core/artwork/prune.go @@ -19,11 +19,11 @@ func Prune(ctx context.Context, ds model.DataStore, store *ImageStore) error { return err } if len(orphans) > 0 { - arts, err := repo.GetBatch(orphans) + arts, err := repo.GetImages(orphans) if err != nil { return err } - if err := repo.Delete(orphans...); err != nil { + if err := repo.DeleteImages(orphans...); err != nil { return err } for _, a := range arts { @@ -35,7 +35,7 @@ func Prune(ctx context.Context, ds model.DataStore, store *ImageStore) error { } removed, err := store.Sweep(func(hash string) bool { - _, err := repo.Get(hash) + _, err := repo.GetImage(hash) return !errors.Is(err, model.ErrNotFound) }) if err != nil { diff --git a/core/artwork/prune_test.go b/core/artwork/prune_test.go index 925e46fd1..68211879d 100644 --- a/core/artwork/prune_test.go +++ b/core/artwork/prune_test.go @@ -17,7 +17,7 @@ type flakyGetArtworkRepo struct { *tests.MockArtworkRepo } -func (f *flakyGetArtworkRepo) Get(string) (*model.Artwork, error) { +func (f *flakyGetArtworkRepo) GetImage(string) (*model.Artwork, error) { return nil, errors.New("db locked") } @@ -36,18 +36,18 @@ var _ = Describe("Prune", func() { data := []byte("orphan-bytes") h, _ := HashImage(bytes.NewReader(data)) Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) - Expect(awRepo.Put(&model.Artwork{Hash: h, Mime: "image/jpeg", + Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg", CreatedAt: time.Now().Add(-2 * time.Hour)})).To(Succeed()) awRepo.OrphanHashes = []string{h} kept := []byte("kept-bytes") hk, _ := HashImage(bytes.NewReader(kept)) Expect(store.Write(hk, "image/jpeg", bytes.NewReader(kept))).To(Succeed()) - Expect(awRepo.Put(&model.Artwork{Hash: hk, Mime: "image/jpeg"})).To(Succeed()) + Expect(awRepo.PutImage(&model.Artwork{Hash: hk, Mime: "image/jpeg"})).To(Succeed()) Expect(Prune(context.Background(), ds, store)).To(Succeed()) - _, err := awRepo.Get(h) + _, err := awRepo.GetImage(h) Expect(err).To(MatchError(model.ErrNotFound)) _, err = store.Open(h, "image/jpeg") Expect(os.IsNotExist(err)).To(BeTrue()) diff --git a/model/artwork.go b/model/artwork.go index dd6a3f436..8fe2bc7fe 100644 --- a/model/artwork.go +++ b/model/artwork.go @@ -55,21 +55,17 @@ const ( ) type ArtworkRepository interface { - Get(hash string) (*Artwork, error) - Put(a *Artwork) error - GetBatch(hashes []string) (map[string]Artwork, error) - // GetOrphanHashes returns hashes referenced by no item_artwork row and older than cutoff. + // Image identity (artwork table) + GetImage(hash string) (*Artwork, error) + PutImage(a *Artwork) error + GetImages(hashes []string) (map[string]Artwork, error) GetOrphanHashes(createdBefore time.Time) ([]string, error) - Delete(hashes ...string) error -} - -type ItemArtworkRepository interface { - Get(kind, id, imageType string) (*ItemArtwork, error) - Put(ia *ItemArtwork) error + DeleteImages(hashes ...string) error + // Per-item state (item_artwork table) + GetItemArtwork(kind, id, imageType string) (*ItemArtwork, error) + PutItemArtwork(ia *ItemArtwork) error DeleteForItem(kind, id string) error - // GetInfoForItems hydrates a page: one batched query, item_artwork joined to artwork. GetInfoForItems(kind string, ids []string) (map[string]ItemArtworkInfo, error) - // EnqueueStaleAbsent inserts queue rows (priority Recheck) for absent states older than cutoff. EnqueueStaleAbsent(kind string, attemptedBefore time.Time) (int64, error) } diff --git a/model/datastore.go b/model/datastore.go index c72eb9a96..273ca714b 100644 --- a/model/datastore.go +++ b/model/datastore.go @@ -41,7 +41,6 @@ type DataStore interface { Scrobble(ctx context.Context) ScrobbleRepository Plugin(ctx context.Context) PluginRepository Artwork(ctx context.Context) ArtworkRepository - ItemArtwork(ctx context.Context) ItemArtworkRepository ArtworkQueue(ctx context.Context) ArtworkQueueRepository Resource(ctx context.Context, model any) ResourceRepository diff --git a/persistence/artwork_repository.go b/persistence/artwork_repository.go index a49f30597..839a390ed 100644 --- a/persistence/artwork_repository.go +++ b/persistence/artwork_repository.go @@ -21,7 +21,7 @@ func NewArtworkRepository(ctx context.Context, db dbx.Builder) model.ArtworkRepo return r } -func (r *artworkRepository) Get(hash string) (*model.Artwork, error) { +func (r *artworkRepository) GetImage(hash string) (*model.Artwork, error) { sel := Select("*").From(r.tableName).Where(Eq{"hash": hash}) var res model.Artwork if err := r.queryOne(sel, &res); err != nil { @@ -30,7 +30,7 @@ func (r *artworkRepository) Get(hash string) (*model.Artwork, error) { return &res, nil } -func (r *artworkRepository) Put(a *model.Artwork) error { +func (r *artworkRepository) PutImage(a *model.Artwork) error { if a.CreatedAt.IsZero() { a.CreatedAt = time.Now() } @@ -45,7 +45,7 @@ func (r *artworkRepository) Put(a *model.Artwork) error { return err } -func (r *artworkRepository) GetBatch(hashes []string) (map[string]model.Artwork, error) { +func (r *artworkRepository) GetImages(hashes []string) (map[string]model.Artwork, error) { res := map[string]model.Artwork{} if len(hashes) == 0 { return res, nil @@ -72,11 +72,82 @@ func (r *artworkRepository) GetOrphanHashes(createdBefore time.Time) ([]string, return hashes, err } -func (r *artworkRepository) Delete(hashes ...string) error { +func (r *artworkRepository) DeleteImages(hashes ...string) error { if len(hashes) == 0 { return nil } return r.delete(Eq{"hash": hashes}) } +func (r *artworkRepository) GetItemArtwork(kind, id, imageType string) (*model.ItemArtwork, error) { + sel := Select("*").From("item_artwork"). + 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 *artworkRepository) PutItemArtwork(ia *model.ItemArtwork) error { + if ia.ImageType == "" { + ia.ImageType = model.ImageTypePrimary + } + ia.UpdatedAt = time.Now() + ins := Insert("item_artwork").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 *artworkRepository) DeleteForItem(kind, id string) error { + del := Delete("item_artwork").Where(Eq{"item_kind": kind, "item_id": id}) + _, err := r.executeSQL(del) + return err +} + +func (r *artworkRepository) GetInfoForItems(kind string, ids []string) (map[string]model.ItemArtworkInfo, error) { + 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("item_artwork 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 *artworkRepository) EnqueueStaleAbsent(kind string, attemptedBefore time.Time) (int64, error) { + 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.ArtworkRepository = (*artworkRepository)(nil) diff --git a/persistence/artwork_repository_test.go b/persistence/artwork_repository_test.go index e158754a0..b4da4ebbe 100644 --- a/persistence/artwork_repository_test.go +++ b/persistence/artwork_repository_test.go @@ -28,9 +28,9 @@ var _ = Describe("ArtworkRepository", func() { It("stores and retrieves an artwork by hash", func() { a := &model.Artwork{Hash: "abc123", Mime: "image/jpeg", Width: 500, Height: 500, SizeBytes: 1234, BlurHash: "LKO2?U%2Tw=w"} - Expect(repo.Put(a)).To(Succeed()) + Expect(repo.PutImage(a)).To(Succeed()) - got, err := repo.Get("abc123") + got, err := repo.GetImage("abc123") Expect(err).ToNot(HaveOccurred()) Expect(got.Mime).To(Equal("image/jpeg")) Expect(got.BlurHash).To(Equal("LKO2?U%2Tw=w")) @@ -39,32 +39,31 @@ var _ = Describe("ArtworkRepository", func() { It("is idempotent on Put (upsert by hash)", func() { a := &model.Artwork{Hash: "dup1", Mime: "image/png"} - Expect(repo.Put(a)).To(Succeed()) + Expect(repo.PutImage(a)).To(Succeed()) a.BlurHash = "XYZ" - Expect(repo.Put(a)).To(Succeed()) - got, _ := repo.Get("dup1") + Expect(repo.PutImage(a)).To(Succeed()) + got, _ := repo.GetImage("dup1") Expect(got.BlurHash).To(Equal("XYZ")) }) It("returns ErrNotFound for a missing hash", func() { - _, err := repo.Get("nope") + _, err := repo.GetImage("nope") Expect(err).To(MatchError(model.ErrNotFound)) }) It("fetches a batch", func() { - Expect(repo.Put(&model.Artwork{Hash: "b1", Mime: "image/jpeg"})).To(Succeed()) - Expect(repo.Put(&model.Artwork{Hash: "b2", Mime: "image/png"})).To(Succeed()) - got, err := repo.GetBatch([]string{"b1", "b2", "missing"}) + Expect(repo.PutImage(&model.Artwork{Hash: "b1", Mime: "image/jpeg"})).To(Succeed()) + Expect(repo.PutImage(&model.Artwork{Hash: "b2", Mime: "image/png"})).To(Succeed()) + got, err := repo.GetImages([]string{"b1", "b2", "missing"}) Expect(err).ToNot(HaveOccurred()) Expect(got).To(HaveLen(2)) Expect(got["b2"].Mime).To(Equal("image/png")) }) It("finds orphans older than cutoff, honoring item_artwork references", func() { - Expect(repo.Put(&model.Artwork{Hash: "orph1", Mime: "image/jpeg"})).To(Succeed()) - Expect(repo.Put(&model.Artwork{Hash: "ref1", Mime: "image/jpeg"})).To(Succeed()) - iaRepo := NewItemArtworkRepository(context.Background(), GetDBXBuilder()) - Expect(iaRepo.Put(&model.ItemArtwork{ItemKind: "al", ItemID: "a1", ImageType: model.ImageTypePrimary, Hash: "ref1", Source: "folder"})).To(Succeed()) + Expect(repo.PutImage(&model.Artwork{Hash: "orph1", Mime: "image/jpeg"})).To(Succeed()) + Expect(repo.PutImage(&model.Artwork{Hash: "ref1", Mime: "image/jpeg"})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "a1", ImageType: model.ImageTypePrimary, Hash: "ref1", Source: "folder"})).To(Succeed()) orphans, err := repo.GetOrphanHashes(time.Now().Add(time.Minute)) Expect(err).ToNot(HaveOccurred()) @@ -77,9 +76,80 @@ var _ = Describe("ArtworkRepository", func() { }) It("deletes by hashes", func() { - Expect(repo.Put(&model.Artwork{Hash: "d1", Mime: "image/jpeg"})).To(Succeed()) - Expect(repo.Delete("d1")).To(Succeed()) - _, err := repo.Get("d1") + Expect(repo.PutImage(&model.Artwork{Hash: "d1", Mime: "image/jpeg"})).To(Succeed()) + Expect(repo.DeleteImages("d1")).To(Succeed()) + _, err := repo.GetImage("d1") Expect(err).To(MatchError(model.ErrNotFound)) }) }) + +var _ = Describe("ArtworkRepository item state", func() { + var repo model.ArtworkRepository + + BeforeEach(func() { + clearArtworkTables() + repo = 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.PutItemArtwork(ia)).To(Succeed()) + ia.Source = "embedded" + Expect(repo.PutItemArtwork(ia)).To(Succeed()) + + got, err := repo.GetItemArtwork("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.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "ar1", + ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()})).To(Succeed()) + got, err := repo.GetItemArtwork("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(repo.PutImage(&model.Artwork{Hash: "h9", Mime: "image/jpeg", BlurHash: "BH9"})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "x1", ImageType: model.ImageTypePrimary, Hash: "h9", Source: "folder"})).To(Succeed()) + Expect(repo.PutItemArtwork(&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.PutItemArtwork(&model.ItemArtwork{ItemKind: "pl", ItemID: "p1", ImageType: model.ImageTypePrimary, Hash: "h1"})).To(Succeed()) + Expect(repo.DeleteForItem("pl", "p1")).To(Succeed()) + _, err := repo.GetItemArtwork("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.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "stale1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "fresh1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()})).To(Succeed()) + Expect(repo.PutItemArtwork(&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)) + }) +}) diff --git a/persistence/item_artwork_repository.go b/persistence/item_artwork_repository.go deleted file mode 100644 index 08b4caf3c..000000000 --- a/persistence/item_artwork_repository.go +++ /dev/null @@ -1,93 +0,0 @@ -package persistence - -import ( - "context" - "time" - - . "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/model" - "github.com/pocketbase/dbx" -) - -type itemArtworkRepository struct { - sqlRepository -} - -func NewItemArtworkRepository(ctx context.Context, db dbx.Builder) model.ItemArtworkRepository { - r := &itemArtworkRepository{} - r.ctx = ctx - r.db = db - r.tableName = "item_artwork" - return r -} - -func (r *itemArtworkRepository) Get(kind, id, imageType string) (*model.ItemArtwork, error) { - 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 { - 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 r.delete(Eq{"item_kind": kind, "item_id": id}) -} - -func (r *itemArtworkRepository) GetInfoForItems(kind string, ids []string) (map[string]model.ItemArtworkInfo, error) { - 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) { - 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 deleted file mode 100644 index 6aec55d89..000000000 --- a/persistence/item_artwork_repository_test.go +++ /dev/null @@ -1,83 +0,0 @@ -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)) - }) -}) diff --git a/persistence/persistence.go b/persistence/persistence.go index ed6d29865..9d3a33cfc 100644 --- a/persistence/persistence.go +++ b/persistence/persistence.go @@ -101,10 +101,6 @@ func (s *SQLStore) Artwork(ctx context.Context) model.ArtworkRepository { return NewArtworkRepository(ctx, s.getDBXBuilder()) } -func (s *SQLStore) ItemArtwork(ctx context.Context) model.ItemArtworkRepository { - return NewItemArtworkRepository(ctx, s.getDBXBuilder()) -} - func (s *SQLStore) ArtworkQueue(ctx context.Context) model.ArtworkQueueRepository { return NewArtworkQueueRepository(ctx, s.getDBXBuilder()) } diff --git a/tests/mock_artwork_repo.go b/tests/mock_artwork_repo.go index a30ffe97b..3989499ce 100644 --- a/tests/mock_artwork_repo.go +++ b/tests/mock_artwork_repo.go @@ -9,15 +9,18 @@ import ( type MockArtworkRepo struct { model.ArtworkRepository Data map[string]model.Artwork + ItemData map[string]model.ItemArtwork // key: kind + "|" + id + "|" + imageType OrphanHashes []string Err error } func CreateMockArtworkRepo() *MockArtworkRepo { - return &MockArtworkRepo{Data: map[string]model.Artwork{}} + return &MockArtworkRepo{Data: map[string]model.Artwork{}, ItemData: map[string]model.ItemArtwork{}} } -func (m *MockArtworkRepo) Get(hash string) (*model.Artwork, error) { +func iaKey(kind, id, imageType string) string { return kind + "|" + id + "|" + imageType } + +func (m *MockArtworkRepo) GetImage(hash string) (*model.Artwork, error) { if m.Err != nil { return nil, m.Err } @@ -27,7 +30,7 @@ func (m *MockArtworkRepo) Get(hash string) (*model.Artwork, error) { return nil, model.ErrNotFound } -func (m *MockArtworkRepo) Put(a *model.Artwork) error { +func (m *MockArtworkRepo) PutImage(a *model.Artwork) error { if m.Err != nil { return m.Err } @@ -38,7 +41,7 @@ func (m *MockArtworkRepo) Put(a *model.Artwork) error { return nil } -func (m *MockArtworkRepo) GetBatch(hashes []string) (map[string]model.Artwork, error) { +func (m *MockArtworkRepo) GetImages(hashes []string) (map[string]model.Artwork, error) { if m.Err != nil { return nil, m.Err } @@ -55,7 +58,7 @@ func (m *MockArtworkRepo) GetOrphanHashes(createdBefore time.Time) ([]string, er return m.OrphanHashes, m.Err } -func (m *MockArtworkRepo) Delete(hashes ...string) error { +func (m *MockArtworkRepo) DeleteImages(hashes ...string) error { if m.Err != nil { return m.Err } @@ -64,3 +67,51 @@ func (m *MockArtworkRepo) Delete(hashes ...string) error { } return nil } + +func (m *MockArtworkRepo) GetItemArtwork(kind, id, imageType string) (*model.ItemArtwork, error) { + if m.Err != nil { + return nil, m.Err + } + if ia, ok := m.ItemData[iaKey(kind, id, imageType)]; ok { + return &ia, nil + } + return nil, model.ErrNotFound +} + +func (m *MockArtworkRepo) PutItemArtwork(ia *model.ItemArtwork) error { + if m.Err != nil { + return m.Err + } + ia.UpdatedAt = time.Now() + m.ItemData[iaKey(ia.ItemKind, ia.ItemID, ia.ImageType)] = *ia + return nil +} + +func (m *MockArtworkRepo) DeleteForItem(kind, id string) error { + if m.Err != nil { + return m.Err + } + for k, ia := range m.ItemData { + if ia.ItemKind == kind && ia.ItemID == id { + delete(m.ItemData, k) + } + } + return nil +} + +func (m *MockArtworkRepo) GetInfoForItems(kind string, ids []string) (map[string]model.ItemArtworkInfo, error) { + if m.Err != nil { + return nil, m.Err + } + res := map[string]model.ItemArtworkInfo{} + for _, id := range ids { + if ia, ok := m.ItemData[iaKey(kind, id, model.ImageTypePrimary)]; ok { + res[id] = model.ItemArtworkInfo{ItemID: id, Hash: ia.Hash, Absent: ia.Hash == ""} + } + } + return res, nil +} + +func (m *MockArtworkRepo) EnqueueStaleAbsent(kind string, attemptedBefore time.Time) (int64, error) { + return 0, m.Err +} diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go index 345a376e6..a6ca29df5 100644 --- a/tests/mock_data_store.go +++ b/tests/mock_data_store.go @@ -29,7 +29,6 @@ type MockDataStore struct { MockedRadio model.RadioRepository MockedPlugin model.PluginRepository MockedArtwork model.ArtworkRepository - MockedItemArtwork model.ItemArtworkRepository MockedArtworkQueue model.ArtworkQueueRepository scrobbleBufferMu sync.Mutex repoMu sync.Mutex @@ -261,17 +260,6 @@ func (db *MockDataStore) Artwork(ctx context.Context) model.ArtworkRepository { return db.MockedArtwork } -func (db *MockDataStore) ItemArtwork(ctx context.Context) model.ItemArtworkRepository { - if db.MockedItemArtwork != nil { - return db.MockedItemArtwork - } - if db.RealDS != nil { - return db.RealDS.ItemArtwork(ctx) - } - db.MockedItemArtwork = CreateMockItemArtworkRepo() - return db.MockedItemArtwork -} - func (db *MockDataStore) ArtworkQueue(ctx context.Context) model.ArtworkQueueRepository { if db.MockedArtworkQueue != nil { return db.MockedArtworkQueue diff --git a/tests/mock_item_artwork_repo.go b/tests/mock_item_artwork_repo.go deleted file mode 100644 index 7d9cfbd9e..000000000 --- a/tests/mock_item_artwork_repo.go +++ /dev/null @@ -1,67 +0,0 @@ -package tests - -import ( - "time" - - "github.com/navidrome/navidrome/model" -) - -type MockItemArtworkRepo struct { - model.ItemArtworkRepository - Data map[string]model.ItemArtwork // key: kind + "|" + id + "|" + imageType - Err error -} - -func CreateMockItemArtworkRepo() *MockItemArtworkRepo { - return &MockItemArtworkRepo{Data: map[string]model.ItemArtwork{}} -} - -func iaKey(kind, id, imageType string) string { return kind + "|" + id + "|" + imageType } - -func (m *MockItemArtworkRepo) Get(kind, id, imageType string) (*model.ItemArtwork, error) { - if m.Err != nil { - return nil, m.Err - } - if ia, ok := m.Data[iaKey(kind, id, imageType)]; ok { - return &ia, nil - } - return nil, model.ErrNotFound -} - -func (m *MockItemArtworkRepo) Put(ia *model.ItemArtwork) error { - if m.Err != nil { - return m.Err - } - ia.UpdatedAt = time.Now() - m.Data[iaKey(ia.ItemKind, ia.ItemID, ia.ImageType)] = *ia - return nil -} - -func (m *MockItemArtworkRepo) DeleteForItem(kind, id string) error { - if m.Err != nil { - return m.Err - } - for k, ia := range m.Data { - if ia.ItemKind == kind && ia.ItemID == id { - delete(m.Data, k) - } - } - return nil -} - -func (m *MockItemArtworkRepo) GetInfoForItems(kind string, ids []string) (map[string]model.ItemArtworkInfo, error) { - if m.Err != nil { - return nil, m.Err - } - res := map[string]model.ItemArtworkInfo{} - for _, id := range ids { - if ia, ok := m.Data[iaKey(kind, id, model.ImageTypePrimary)]; ok { - res[id] = model.ItemArtworkInfo{ItemID: id, Hash: ia.Hash, Absent: ia.Hash == ""} - } - } - return res, nil -} - -func (m *MockItemArtworkRepo) EnqueueStaleAbsent(kind string, attemptedBefore time.Time) (int64, error) { - return 0, m.Err -}