mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
feat(artwork): add artwork models, repository interfaces and mocks
This commit is contained in:
parent
7aacb01f4f
commit
6cce65f759
85
model/artwork.go
Normal file
85
model/artwork.go
Normal file
@ -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)
|
||||
}
|
||||
@ -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
|
||||
|
||||
|
||||
43
persistence/artwork_queue_repository.go
Normal file
43
persistence/artwork_queue_repository.go
Normal file
@ -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)
|
||||
43
persistence/artwork_repository.go
Normal file
43
persistence/artwork_repository.go
Normal file
@ -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)
|
||||
43
persistence/item_artwork_repository.go
Normal file
43
persistence/item_artwork_repository.go
Normal file
@ -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)
|
||||
@ -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:
|
||||
|
||||
91
tests/mock_artwork_queue_repo.go
Normal file
91
tests/mock_artwork_queue_repo.go
Normal file
@ -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
|
||||
}
|
||||
65
tests/mock_artwork_repo.go
Normal file
65
tests/mock_artwork_repo.go
Normal file
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
67
tests/mock_item_artwork_repo.go
Normal file
67
tests/mock_item_artwork_repo.go
Normal file
@ -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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user