feat(artwork): add artwork models, repository interfaces and mocks

This commit is contained in:
Deluan 2026-07-21 22:35:34 -04:00
parent 7aacb01f4f
commit 6cce65f759
10 changed files with 488 additions and 0 deletions

85
model/artwork.go Normal file
View 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)
}

View File

@ -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

View 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)

View 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)

View 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)

View File

@ -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:

View 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
}

View 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
}

View File

@ -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)
}

View 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
}