From 6cce65f7598f65e2437a3335f3021c813899ebda Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 21 Jul 2026 22:35:34 -0400 Subject: [PATCH] feat(artwork): add artwork models, repository interfaces and mocks --- model/artwork.go | 85 +++++++++++++++++++++++ model/datastore.go | 3 + persistence/artwork_queue_repository.go | 43 ++++++++++++ persistence/artwork_repository.go | 43 ++++++++++++ persistence/item_artwork_repository.go | 43 ++++++++++++ persistence/persistence.go | 12 ++++ tests/mock_artwork_queue_repo.go | 91 +++++++++++++++++++++++++ tests/mock_artwork_repo.go | 65 ++++++++++++++++++ tests/mock_data_store.go | 36 ++++++++++ tests/mock_item_artwork_repo.go | 67 ++++++++++++++++++ 10 files changed, 488 insertions(+) create mode 100644 model/artwork.go create mode 100644 persistence/artwork_queue_repository.go create mode 100644 persistence/artwork_repository.go create mode 100644 persistence/item_artwork_repository.go create mode 100644 tests/mock_artwork_queue_repo.go create mode 100644 tests/mock_artwork_repo.go create mode 100644 tests/mock_item_artwork_repo.go diff --git a/model/artwork.go b/model/artwork.go new file mode 100644 index 000000000..dd6a3f436 --- /dev/null +++ b/model/artwork.go @@ -0,0 +1,85 @@ +package model + +import "time" + +// Artwork is one unique image, identified by the XXH3-64 hash of its bytes. +type Artwork struct { + Hash string `structs:"hash"` + Mime string `structs:"mime"` + Width int `structs:"width"` + Height int `structs:"height"` + SizeBytes int64 `structs:"size_bytes"` + BlurHash string `structs:"blur_hash"` + SourcePath string `structs:"source_path"` + RefMtime int64 `structs:"ref_mtime"` + CreatedAt time.Time `structs:"created_at"` +} + +const ImageTypePrimary = "primary" + +// ItemArtwork is an entity's resolved artwork state. Hash=="" means known absent. +type ItemArtwork struct { + ItemKind string `structs:"item_kind"` + ItemID string `structs:"item_id"` + ImageType string `structs:"image_type"` + Hash string `structs:"hash"` + Source string `structs:"source"` + AttemptedAt time.Time `structs:"attempted_at"` + UpdatedAt time.Time `structs:"updated_at"` +} + +// ItemArtworkInfo is the list-hydration projection (item_artwork joined with artwork). +type ItemArtworkInfo struct { + ItemID string + Hash string + BlurHash string + Absent bool +} + +type ArtworkQueueItem struct { + ItemKind string `structs:"item_kind"` + ItemID string `structs:"item_id"` + ImageType string `structs:"image_type"` + Priority int `structs:"priority"` + Attempts int `structs:"attempts"` + RetryAt time.Time `structs:"retry_at"` + EnqueuedAt time.Time `structs:"enqueued_at"` +} + +// Queue priorities: higher drains first. +const ( + ArtworkPriorityRecheck = 0 + ArtworkPriorityBackfill = 10 + ArtworkPriorityScan = 50 + ArtworkPriorityBump = 100 +) + +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. + GetOrphanHashes(createdBefore time.Time) ([]string, error) + Delete(hashes ...string) error +} + +type ItemArtworkRepository interface { + Get(kind, id, imageType string) (*ItemArtwork, error) + Put(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) +} + +type ArtworkQueueRepository interface { + // Enqueue upserts; an existing row keeps the higher of the two priorities. + Enqueue(items ...ArtworkQueueItem) error + // DequeueBatch returns up to n items with retry_at <= now, priority desc, enqueued_at asc. + DequeueBatch(n int) ([]ArtworkQueueItem, error) + // MarkFailed increments attempts and pushes retry_at into the future. + MarkFailed(kind, id, imageType string, retryAt time.Time) error + Delete(kind, id, imageType string) error + Count() (int64, error) +} diff --git a/model/datastore.go b/model/datastore.go index 94c3c3622..c72eb9a96 100644 --- a/model/datastore.go +++ b/model/datastore.go @@ -40,6 +40,9 @@ type DataStore interface { ScrobbleBuffer(ctx context.Context) ScrobbleBufferRepository 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_queue_repository.go b/persistence/artwork_queue_repository.go new file mode 100644 index 000000000..4b166aa57 --- /dev/null +++ b/persistence/artwork_queue_repository.go @@ -0,0 +1,43 @@ +package persistence + +import ( + "context" + "time" + + "github.com/navidrome/navidrome/model" + "github.com/pocketbase/dbx" +) + +type artworkQueueRepository struct { + sqlRepository +} + +func NewArtworkQueueRepository(ctx context.Context, db dbx.Builder) model.ArtworkQueueRepository { + r := &artworkQueueRepository{} + r.ctx = ctx + r.db = db + r.tableName = "artwork_queue" + return r +} + +func (r *artworkQueueRepository) Enqueue(items ...model.ArtworkQueueItem) error { + return nil +} + +func (r *artworkQueueRepository) DequeueBatch(n int) ([]model.ArtworkQueueItem, error) { + return nil, nil +} + +func (r *artworkQueueRepository) MarkFailed(kind, id, imageType string, retryAt time.Time) error { + return nil +} + +func (r *artworkQueueRepository) Delete(kind, id, imageType string) error { + return nil +} + +func (r *artworkQueueRepository) Count() (int64, error) { + return 0, nil +} + +var _ model.ArtworkQueueRepository = (*artworkQueueRepository)(nil) diff --git a/persistence/artwork_repository.go b/persistence/artwork_repository.go new file mode 100644 index 000000000..4db47741b --- /dev/null +++ b/persistence/artwork_repository.go @@ -0,0 +1,43 @@ +package persistence + +import ( + "context" + "time" + + "github.com/navidrome/navidrome/model" + "github.com/pocketbase/dbx" +) + +type artworkRepository struct { + sqlRepository +} + +func NewArtworkRepository(ctx context.Context, db dbx.Builder) model.ArtworkRepository { + r := &artworkRepository{} + r.ctx = ctx + r.db = db + r.tableName = "artwork" + return r +} + +func (r *artworkRepository) Get(hash string) (*model.Artwork, error) { + return nil, model.ErrNotFound +} + +func (r *artworkRepository) Put(a *model.Artwork) error { + return nil +} + +func (r *artworkRepository) GetBatch(hashes []string) (map[string]model.Artwork, error) { + return nil, nil +} + +func (r *artworkRepository) GetOrphanHashes(createdBefore time.Time) ([]string, error) { + return nil, nil +} + +func (r *artworkRepository) Delete(hashes ...string) error { + return nil +} + +var _ model.ArtworkRepository = (*artworkRepository)(nil) diff --git a/persistence/item_artwork_repository.go b/persistence/item_artwork_repository.go new file mode 100644 index 000000000..8363264ca --- /dev/null +++ b/persistence/item_artwork_repository.go @@ -0,0 +1,43 @@ +package persistence + +import ( + "context" + "time" + + "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) { + return nil, model.ErrNotFound +} + +func (r *itemArtworkRepository) Put(ia *model.ItemArtwork) error { + return nil +} + +func (r *itemArtworkRepository) DeleteForItem(kind, id string) error { + return nil +} + +func (r *itemArtworkRepository) GetInfoForItems(kind string, ids []string) (map[string]model.ItemArtworkInfo, error) { + return nil, nil +} + +func (r *itemArtworkRepository) EnqueueStaleAbsent(kind string, attemptedBefore time.Time) (int64, error) { + return 0, nil +} + +var _ model.ItemArtworkRepository = (*itemArtworkRepository)(nil) diff --git a/persistence/persistence.go b/persistence/persistence.go index 93f0e3e71..ed6d29865 100644 --- a/persistence/persistence.go +++ b/persistence/persistence.go @@ -97,6 +97,18 @@ func (s *SQLStore) Plugin(ctx context.Context) model.PluginRepository { return NewPluginRepository(ctx, s.getDBXBuilder()) } +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()) +} + func (s *SQLStore) Resource(ctx context.Context, m any) model.ResourceRepository { switch m.(type) { case model.User: diff --git a/tests/mock_artwork_queue_repo.go b/tests/mock_artwork_queue_repo.go new file mode 100644 index 000000000..b08a1297c --- /dev/null +++ b/tests/mock_artwork_queue_repo.go @@ -0,0 +1,91 @@ +package tests + +import ( + "sort" + "time" + + "github.com/navidrome/navidrome/model" +) + +type MockArtworkQueueRepo struct { + model.ArtworkQueueRepository + Data map[string]model.ArtworkQueueItem // key: kind + "|" + id + "|" + imageType + Err error +} + +func CreateMockArtworkQueueRepo() *MockArtworkQueueRepo { + return &MockArtworkQueueRepo{Data: map[string]model.ArtworkQueueItem{}} +} + +func (m *MockArtworkQueueRepo) Enqueue(items ...model.ArtworkQueueItem) error { + if m.Err != nil { + return m.Err + } + for _, it := range items { + k := iaKey(it.ItemKind, it.ItemID, it.ImageType) + if prev, ok := m.Data[k]; ok && prev.Priority > it.Priority { + it.Priority = prev.Priority + } + if it.EnqueuedAt.IsZero() { + it.EnqueuedAt = time.Now() + } + if it.RetryAt.IsZero() { + it.RetryAt = time.Now() + } + m.Data[k] = it + } + return nil +} + +func (m *MockArtworkQueueRepo) DequeueBatch(n int) ([]model.ArtworkQueueItem, error) { + if m.Err != nil { + return nil, m.Err + } + var res []model.ArtworkQueueItem + now := time.Now() + for _, it := range m.Data { + if !it.RetryAt.After(now) { + res = append(res, it) + } + } + sort.Slice(res, func(i, j int) bool { + if res[i].Priority != res[j].Priority { + return res[i].Priority > res[j].Priority + } + return res[i].EnqueuedAt.Before(res[j].EnqueuedAt) + }) + if len(res) > n { + res = res[:n] + } + return res, nil +} + +func (m *MockArtworkQueueRepo) MarkFailed(kind, id, imageType string, retryAt time.Time) error { + if m.Err != nil { + return m.Err + } + k := iaKey(kind, id, imageType) + it, ok := m.Data[k] + if !ok { + return model.ErrNotFound + } + it.Attempts++ + it.RetryAt = retryAt + m.Data[k] = it + return nil +} + +func (m *MockArtworkQueueRepo) Delete(kind, id, imageType string) error { + if m.Err != nil { + return m.Err + } + delete(m.Data, iaKey(kind, id, imageType)) + return nil +} + +func (m *MockArtworkQueueRepo) Count() (int64, error) { + if m.Err != nil { + return 0, m.Err + } + return int64(len(m.Data)), nil +} diff --git a/tests/mock_artwork_repo.go b/tests/mock_artwork_repo.go new file mode 100644 index 000000000..c8b95a025 --- /dev/null +++ b/tests/mock_artwork_repo.go @@ -0,0 +1,65 @@ +package tests + +import ( + "time" + + "github.com/navidrome/navidrome/model" +) + +type MockArtworkRepo struct { + model.ArtworkRepository + Data map[string]model.Artwork + Err error +} + +func CreateMockArtworkRepo() *MockArtworkRepo { + return &MockArtworkRepo{Data: map[string]model.Artwork{}} +} + +func (m *MockArtworkRepo) Get(hash string) (*model.Artwork, error) { + if m.Err != nil { + return nil, m.Err + } + if a, ok := m.Data[hash]; ok { + return &a, nil + } + return nil, model.ErrNotFound +} + +func (m *MockArtworkRepo) Put(a *model.Artwork) error { + if m.Err != nil { + return m.Err + } + if a.CreatedAt.IsZero() { + a.CreatedAt = time.Now() + } + m.Data[a.Hash] = *a + return nil +} + +func (m *MockArtworkRepo) GetBatch(hashes []string) (map[string]model.Artwork, error) { + if m.Err != nil { + return nil, m.Err + } + res := map[string]model.Artwork{} + for _, h := range hashes { + if a, ok := m.Data[h]; ok { + res[h] = a + } + } + return res, nil +} + +func (m *MockArtworkRepo) GetOrphanHashes(createdBefore time.Time) ([]string, error) { + return nil, m.Err +} + +func (m *MockArtworkRepo) Delete(hashes ...string) error { + if m.Err != nil { + return m.Err + } + for _, h := range hashes { + delete(m.Data, h) + } + return nil +} diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go index e016a28de..345a376e6 100644 --- a/tests/mock_data_store.go +++ b/tests/mock_data_store.go @@ -28,6 +28,9 @@ type MockDataStore struct { MockedScrobble model.ScrobbleRepository MockedRadio model.RadioRepository MockedPlugin model.PluginRepository + MockedArtwork model.ArtworkRepository + MockedItemArtwork model.ItemArtworkRepository + MockedArtworkQueue model.ArtworkQueueRepository scrobbleBufferMu sync.Mutex repoMu sync.Mutex @@ -247,6 +250,39 @@ func (db *MockDataStore) Plugin(ctx context.Context) model.PluginRepository { return db.MockedPlugin } +func (db *MockDataStore) Artwork(ctx context.Context) model.ArtworkRepository { + if db.MockedArtwork != nil { + return db.MockedArtwork + } + if db.RealDS != nil { + return db.RealDS.Artwork(ctx) + } + db.MockedArtwork = CreateMockArtworkRepo() + 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 + } + if db.RealDS != nil { + return db.RealDS.ArtworkQueue(ctx) + } + db.MockedArtworkQueue = CreateMockArtworkQueueRepo() + return db.MockedArtworkQueue +} + func (db *MockDataStore) WithTx(block func(tx model.DataStore) error, label ...string) error { return block(db) } diff --git a/tests/mock_item_artwork_repo.go b/tests/mock_item_artwork_repo.go new file mode 100644 index 000000000..7d9cfbd9e --- /dev/null +++ b/tests/mock_item_artwork_repo.go @@ -0,0 +1,67 @@ +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 +}