mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
refactor(artwork): merge item artwork state into ArtworkRepository
This commit is contained in:
parent
b16ef725c9
commit
4f835437a9
@ -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 {
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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))
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
@ -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))
|
||||
})
|
||||
})
|
||||
@ -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())
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user