From 7aacb01f4f5abd05a9b8ee9ef0b3c10161a2d951 Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 21 Jul 2026 22:31:03 -0400 Subject: [PATCH 01/87] feat(artwork): add artwork, item_artwork and artwork_queue tables --- .../20260722023032_add_artwork_tables.sql | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 db/migrations/20260722023032_add_artwork_tables.sql diff --git a/db/migrations/20260722023032_add_artwork_tables.sql b/db/migrations/20260722023032_add_artwork_tables.sql new file mode 100644 index 000000000..94e0ebe5f --- /dev/null +++ b/db/migrations/20260722023032_add_artwork_tables.sql @@ -0,0 +1,41 @@ +-- +goose Up +CREATE TABLE artwork ( + hash TEXT PRIMARY KEY, + mime TEXT NOT NULL, + width INTEGER NOT NULL DEFAULT 0, + height INTEGER NOT NULL DEFAULT 0, + size_bytes INTEGER NOT NULL DEFAULT 0, + blur_hash TEXT NOT NULL DEFAULT '', + source_path TEXT NOT NULL DEFAULT '', + ref_mtime INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE item_artwork ( + item_kind TEXT NOT NULL, + item_id TEXT NOT NULL, + image_type TEXT NOT NULL DEFAULT 'primary', + hash TEXT NOT NULL DEFAULT '', + source TEXT NOT NULL DEFAULT '', + attempted_at TIMESTAMP, + updated_at TIMESTAMP, + PRIMARY KEY (item_kind, item_id, image_type) +) WITHOUT ROWID; +CREATE INDEX ix_item_artwork_hash ON item_artwork(hash); + +CREATE TABLE artwork_queue ( + item_kind TEXT NOT NULL, + item_id TEXT NOT NULL, + image_type TEXT NOT NULL DEFAULT 'primary', + priority INTEGER NOT NULL DEFAULT 0, + attempts INTEGER NOT NULL DEFAULT 0, + retry_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + enqueued_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (item_kind, item_id, image_type) +) WITHOUT ROWID; +CREATE INDEX ix_artwork_queue_drain ON artwork_queue(retry_at, priority, enqueued_at); + +-- +goose Down +DROP TABLE artwork_queue; +DROP TABLE item_artwork; +DROP TABLE artwork; From 6cce65f7598f65e2437a3335f3021c813899ebda Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 21 Jul 2026 22:35:34 -0400 Subject: [PATCH 02/87] 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 +} From f926539c044a87219e0c6be915ea4d2c2365f8b1 Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 21 Jul 2026 22:44:24 -0400 Subject: [PATCH 03/87] feat(artwork): implement artwork repository --- persistence/artwork_repository.go | 49 +++++++++++++-- persistence/artwork_repository_test.go | 85 ++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 5 deletions(-) create mode 100644 persistence/artwork_repository_test.go diff --git a/persistence/artwork_repository.go b/persistence/artwork_repository.go index 4db47741b..a49f30597 100644 --- a/persistence/artwork_repository.go +++ b/persistence/artwork_repository.go @@ -4,6 +4,7 @@ import ( "context" "time" + . "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/model" "github.com/pocketbase/dbx" ) @@ -21,23 +22,61 @@ func NewArtworkRepository(ctx context.Context, db dbx.Builder) model.ArtworkRepo } func (r *artworkRepository) Get(hash string) (*model.Artwork, error) { - return nil, model.ErrNotFound + sel := Select("*").From(r.tableName).Where(Eq{"hash": hash}) + var res model.Artwork + if err := r.queryOne(sel, &res); err != nil { + return nil, err + } + return &res, nil } func (r *artworkRepository) Put(a *model.Artwork) error { - return nil + if a.CreatedAt.IsZero() { + a.CreatedAt = time.Now() + } + ins := Insert(r.tableName).SetMap(map[string]any{ + "hash": a.Hash, "mime": a.Mime, "width": a.Width, "height": a.Height, + "size_bytes": a.SizeBytes, "blur_hash": a.BlurHash, + "source_path": a.SourcePath, "ref_mtime": a.RefMtime, "created_at": a.CreatedAt, + }).Suffix(`ON CONFLICT (hash) DO UPDATE SET mime=excluded.mime, width=excluded.width, + height=excluded.height, size_bytes=excluded.size_bytes, blur_hash=excluded.blur_hash, + source_path=excluded.source_path, ref_mtime=excluded.ref_mtime`) + _, err := r.executeSQL(ins) + return err } func (r *artworkRepository) GetBatch(hashes []string) (map[string]model.Artwork, error) { - return nil, nil + res := map[string]model.Artwork{} + if len(hashes) == 0 { + return res, nil + } + sel := Select("*").From(r.tableName).Where(Eq{"hash": hashes}) + var all []model.Artwork + if err := r.queryAll(sel, &all); err != nil { + return nil, err + } + for _, a := range all { + res[a.Hash] = a + } + return res, nil } func (r *artworkRepository) GetOrphanHashes(createdBefore time.Time) ([]string, error) { - return nil, nil + sel := Select("hash").From(r.tableName). + Where(And{ + Lt{"created_at": createdBefore}, + Expr("hash NOT IN (SELECT hash FROM item_artwork WHERE hash <> '')"), + }) + var hashes []string + err := r.queryAllSlice(sel, &hashes) + return hashes, err } func (r *artworkRepository) Delete(hashes ...string) error { - return nil + if len(hashes) == 0 { + return nil + } + return r.delete(Eq{"hash": hashes}) } var _ model.ArtworkRepository = (*artworkRepository)(nil) diff --git a/persistence/artwork_repository_test.go b/persistence/artwork_repository_test.go new file mode 100644 index 000000000..e158754a0 --- /dev/null +++ b/persistence/artwork_repository_test.go @@ -0,0 +1,85 @@ +package persistence + +import ( + "context" + "time" + + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// clearArtworkTables resets the shared test DB's artwork tables so specs don't leak state. +func clearArtworkTables() { + db := GetDBXBuilder() + for _, t := range []string{"artwork_queue", "item_artwork", "artwork"} { + _, err := db.NewQuery("DELETE FROM " + t).Execute() + Expect(err).ToNot(HaveOccurred()) + } +} + +var _ = Describe("ArtworkRepository", func() { + var repo model.ArtworkRepository + + BeforeEach(func() { + clearArtworkTables() + repo = NewArtworkRepository(context.Background(), GetDBXBuilder()) + }) + + 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()) + + got, err := repo.Get("abc123") + Expect(err).ToNot(HaveOccurred()) + Expect(got.Mime).To(Equal("image/jpeg")) + Expect(got.BlurHash).To(Equal("LKO2?U%2Tw=w")) + Expect(got.CreatedAt).ToNot(BeZero()) + }) + + It("is idempotent on Put (upsert by hash)", func() { + a := &model.Artwork{Hash: "dup1", Mime: "image/png"} + Expect(repo.Put(a)).To(Succeed()) + a.BlurHash = "XYZ" + Expect(repo.Put(a)).To(Succeed()) + got, _ := repo.Get("dup1") + Expect(got.BlurHash).To(Equal("XYZ")) + }) + + It("returns ErrNotFound for a missing hash", func() { + _, err := repo.Get("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(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()) + + orphans, err := repo.GetOrphanHashes(time.Now().Add(time.Minute)) + Expect(err).ToNot(HaveOccurred()) + Expect(orphans).To(ContainElement("orph1")) + Expect(orphans).ToNot(ContainElement("ref1")) + + orphans, err = repo.GetOrphanHashes(time.Now().Add(-time.Hour)) + Expect(err).ToNot(HaveOccurred()) + Expect(orphans).To(BeEmpty()) + }) + + 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(err).To(MatchError(model.ErrNotFound)) + }) +}) From 14dd57052ede5a55a493ff9b79d453c48b172218 Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 21 Jul 2026 22:45:03 -0400 Subject: [PATCH 04/87] feat(artwork): implement item_artwork repository with batched hydration --- persistence/item_artwork_repository.go | 60 +++++++++++++-- persistence/item_artwork_repository_test.go | 83 +++++++++++++++++++++ 2 files changed, 138 insertions(+), 5 deletions(-) create mode 100644 persistence/item_artwork_repository_test.go diff --git a/persistence/item_artwork_repository.go b/persistence/item_artwork_repository.go index 8363264ca..08b4caf3c 100644 --- a/persistence/item_artwork_repository.go +++ b/persistence/item_artwork_repository.go @@ -4,6 +4,7 @@ import ( "context" "time" + . "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/model" "github.com/pocketbase/dbx" ) @@ -21,23 +22,72 @@ func NewItemArtworkRepository(ctx context.Context, db dbx.Builder) model.ItemArt } func (r *itemArtworkRepository) Get(kind, id, imageType string) (*model.ItemArtwork, error) { - return nil, model.ErrNotFound + 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 { - return nil + 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 nil + return r.delete(Eq{"item_kind": kind, "item_id": id}) } func (r *itemArtworkRepository) GetInfoForItems(kind string, ids []string) (map[string]model.ItemArtworkInfo, error) { - return nil, nil + 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) { - return 0, nil + 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 new file mode 100644 index 000000000..6aec55d89 --- /dev/null +++ b/persistence/item_artwork_repository_test.go @@ -0,0 +1,83 @@ +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)) + }) +}) From fcff9c63e787012badd95232754c22ac55612cab Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 21 Jul 2026 22:45:08 -0400 Subject: [PATCH 05/87] feat(artwork): implement artwork_queue repository --- persistence/artwork_queue_repository.go | 45 ++++++++++++-- persistence/artwork_queue_repository_test.go | 65 ++++++++++++++++++++ 2 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 persistence/artwork_queue_repository_test.go diff --git a/persistence/artwork_queue_repository.go b/persistence/artwork_queue_repository.go index 4b166aa57..fdf88db24 100644 --- a/persistence/artwork_queue_repository.go +++ b/persistence/artwork_queue_repository.go @@ -4,6 +4,7 @@ import ( "context" "time" + . "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/model" "github.com/pocketbase/dbx" ) @@ -21,23 +22,59 @@ func NewArtworkQueueRepository(ctx context.Context, db dbx.Builder) model.Artwor } func (r *artworkQueueRepository) Enqueue(items ...model.ArtworkQueueItem) error { + now := time.Now() + for _, it := range items { + if it.ImageType == "" { + it.ImageType = model.ImageTypePrimary + } + ins := Insert(r.tableName).SetMap(map[string]any{ + "item_kind": it.ItemKind, "item_id": it.ItemID, "image_type": it.ImageType, + "priority": it.Priority, "attempts": 0, "retry_at": now, "enqueued_at": now, + }).Suffix(`ON CONFLICT (item_kind, item_id, image_type) DO UPDATE SET + priority = MAX(priority, excluded.priority), retry_at = excluded.retry_at`) + if _, err := r.executeSQL(ins); err != nil { + return err + } + } return nil } func (r *artworkQueueRepository) DequeueBatch(n int) ([]model.ArtworkQueueItem, error) { - return nil, nil + sel := Select("*").From(r.tableName). + Where(LtOrEq{"retry_at": time.Now()}). + OrderBy("priority DESC", "enqueued_at ASC"). + Limit(uint64(n)) + var res []model.ArtworkQueueItem + err := r.queryAll(sel, &res) + return res, err } func (r *artworkQueueRepository) MarkFailed(kind, id, imageType string, retryAt time.Time) error { - return nil + upd := Update(r.tableName). + Set("attempts", Expr("attempts + 1")). + Set("retry_at", retryAt). + Where(Eq{"item_kind": kind, "item_id": id, "image_type": imageType}) + c, err := r.executeSQL(upd) + if err == nil && c == 0 { + return model.ErrNotFound + } + return err } func (r *artworkQueueRepository) Delete(kind, id, imageType string) error { - return nil + return r.delete(Eq{"item_kind": kind, "item_id": id, "image_type": imageType}) } func (r *artworkQueueRepository) Count() (int64, error) { - return 0, nil + sel := Select("count(*)").From(r.tableName) + var counts []int64 + if err := r.queryAllSlice(sel, &counts); err != nil { + return 0, err + } + if len(counts) == 0 { + return 0, nil + } + return counts[0], nil } var _ model.ArtworkQueueRepository = (*artworkQueueRepository)(nil) diff --git a/persistence/artwork_queue_repository_test.go b/persistence/artwork_queue_repository_test.go new file mode 100644 index 000000000..73665b07c --- /dev/null +++ b/persistence/artwork_queue_repository_test.go @@ -0,0 +1,65 @@ +package persistence + +import ( + "context" + "time" + + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ArtworkQueueRepository", func() { + var repo model.ArtworkQueueRepository + + item := func(kind, id string, prio int) model.ArtworkQueueItem { + return model.ArtworkQueueItem{ItemKind: kind, ItemID: id, + ImageType: model.ImageTypePrimary, Priority: prio} + } + + BeforeEach(func() { + clearArtworkTables() + repo = NewArtworkQueueRepository(context.Background(), GetDBXBuilder()) + }) + + It("enqueues and dequeues by priority then FIFO", func() { + Expect(repo.Enqueue(item("al", "low", model.ArtworkPriorityBackfill))).To(Succeed()) + Expect(repo.Enqueue(item("ar", "high", model.ArtworkPriorityBump))).To(Succeed()) + + got, err := repo.DequeueBatch(10) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(HaveLen(2)) + Expect(got[0].ItemID).To(Equal("high")) + }) + + It("keeps the higher priority on duplicate enqueue", func() { + Expect(repo.Enqueue(item("al", "a1", model.ArtworkPriorityBump))).To(Succeed()) + Expect(repo.Enqueue(item("al", "a1", model.ArtworkPriorityBackfill))).To(Succeed()) + got, _ := repo.DequeueBatch(10) + Expect(got).To(HaveLen(1)) + Expect(got[0].Priority).To(Equal(model.ArtworkPriorityBump)) + }) + + It("hides failed items until retry_at", func() { + Expect(repo.Enqueue(item("al", "f1", model.ArtworkPriorityScan))).To(Succeed()) + Expect(repo.MarkFailed("al", "f1", model.ImageTypePrimary, time.Now().Add(time.Hour))).To(Succeed()) + + got, err := repo.DequeueBatch(10) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(BeEmpty()) + + Expect(repo.MarkFailed("al", "f1", model.ImageTypePrimary, time.Now().Add(-time.Minute))).To(Succeed()) + got, _ = repo.DequeueBatch(10) + Expect(got).To(HaveLen(1)) + Expect(got[0].Attempts).To(Equal(2)) + }) + + It("deletes on completion and counts", func() { + Expect(repo.Enqueue(item("al", "c1", 0))).To(Succeed()) + n, _ := repo.Count() + Expect(n).To(Equal(int64(1))) + Expect(repo.Delete("al", "c1", model.ImageTypePrimary)).To(Succeed()) + n, _ = repo.Count() + Expect(n).To(BeZero()) + }) +}) From b72597821a0bdbf2624985196bf16c516ed09a2b Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 21 Jul 2026 22:53:00 -0400 Subject: [PATCH 06/87] feat(artwork): add content-addressed originals store --- core/artwork/originals/originals.go | 112 ++++++++++++++++++ .../artwork/originals/originals_suite_test.go | 17 +++ core/artwork/originals/originals_test.go | 80 +++++++++++++ go.mod | 2 +- 4 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 core/artwork/originals/originals.go create mode 100644 core/artwork/originals/originals_suite_test.go create mode 100644 core/artwork/originals/originals_test.go diff --git a/core/artwork/originals/originals.go b/core/artwork/originals/originals.go new file mode 100644 index 000000000..12cfe56cc --- /dev/null +++ b/core/artwork/originals/originals.go @@ -0,0 +1,112 @@ +// Package originals is the content-addressed store for artwork images that have +// no library file backing them (external downloads, embedded extractions, generated). +package originals + +import ( + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/zeebo/xxh3" +) + +func Hash(r io.Reader) (string, error) { + d := xxh3.New() + if _, err := io.Copy(d, r); err != nil { + return "", err + } + return fmt.Sprintf("%016x", d.Sum64()), nil +} + +type Store struct { + root string +} + +func New(rootDir string) *Store { + return &Store{root: rootDir} +} + +// extForMime is deliberately NOT mime.ExtensionsByType: extensions are baked into +// content-addressed paths and re-derived on Open, so they must be stable across OSes. +func extForMime(m string) string { + switch m { + case "image/jpeg": + return ".jpg" + case "image/png": + return ".png" + case "image/gif": + return ".gif" + case "image/webp": + return ".webp" + } + return ".img" +} + +func (s *Store) path(hash, mimeType string) string { + return filepath.Join(s.root, hash[0:2], hash[2:4], hash+extForMime(mimeType)) +} + +func (s *Store) Write(hash, mimeType string, r io.Reader) error { + dst := s.path(hash, mimeType) + if _, err := os.Stat(dst); err == nil { + return nil + } + if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(dst), "."+hash+".tmp*") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) + if _, err := io.Copy(tmp, r); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmp.Name(), dst) +} + +func (s *Store) Open(hash, mimeType string) (io.ReadCloser, error) { + return os.Open(s.path(hash, mimeType)) +} + +func (s *Store) Remove(hash, mimeType string) error { + err := os.Remove(s.path(hash, mimeType)) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return err +} + +func (s *Store) Sweep(keep func(hash string) bool) (int, error) { + removed := 0 + err := filepath.WalkDir(s.root, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + name := d.Name() + if strings.HasPrefix(name, ".") { // in-flight temp files + return nil + } + hash := strings.TrimSuffix(name, filepath.Ext(name)) + if !keep(hash) { + // #nosec G122 -- path comes from WalkDir over our own store root, no attacker-controlled symlinks + if err := os.Remove(path); err != nil { + return err + } + removed++ + } + return nil + }) + if errors.Is(err, fs.ErrNotExist) { + return removed, nil + } + return removed, err +} diff --git a/core/artwork/originals/originals_suite_test.go b/core/artwork/originals/originals_suite_test.go new file mode 100644 index 000000000..2686f9ec9 --- /dev/null +++ b/core/artwork/originals/originals_suite_test.go @@ -0,0 +1,17 @@ +package originals_test + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestOriginals(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Originals Store Suite") +} diff --git a/core/artwork/originals/originals_test.go b/core/artwork/originals/originals_test.go new file mode 100644 index 000000000..caa861a6f --- /dev/null +++ b/core/artwork/originals/originals_test.go @@ -0,0 +1,80 @@ +package originals_test + +import ( + "bytes" + "io" + "os" + "path/filepath" + + "github.com/navidrome/navidrome/core/artwork/originals" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Originals Store", func() { + var store *originals.Store + var root string + + BeforeEach(func() { + root = GinkgoT().TempDir() + store = originals.New(root) + }) + + It("hashes deterministically", func() { + h1, err := originals.Hash(bytes.NewReader([]byte("some image bytes"))) + Expect(err).ToNot(HaveOccurred()) + h2, _ := originals.Hash(bytes.NewReader([]byte("some image bytes"))) + Expect(h1).To(Equal(h2)) + Expect(h1).To(HaveLen(16)) + h3, _ := originals.Hash(bytes.NewReader([]byte("other bytes"))) + Expect(h3).ToNot(Equal(h1)) + }) + + It("writes sharded and reads back", func() { + data := []byte("jpeg-bytes") + h, _ := originals.Hash(bytes.NewReader(data)) + Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) + + Expect(filepath.Join(root, h[0:2], h[2:4], h+".jpg")).To(BeAnExistingFile()) + + rc, err := store.Open(h, "image/jpeg") + Expect(err).ToNot(HaveOccurred()) + defer rc.Close() + got, _ := io.ReadAll(rc) + Expect(got).To(Equal(data)) + }) + + It("is idempotent on duplicate writes", func() { + data := []byte("dup") + h, _ := originals.Hash(bytes.NewReader(data)) + Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed()) + Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed()) + }) + + It("returns fs.ErrNotExist for missing images", func() { + _, err := store.Open("beefbeefbeefbeef", "image/jpeg") + Expect(os.IsNotExist(err)).To(BeTrue()) + }) + + It("removes without error when already gone", func() { + Expect(store.Remove("beefbeefbeefbeef", "image/jpeg")).To(Succeed()) + }) + + It("sweeps unknown files, keeps known ones", func() { + d1 := []byte("keep-me") + h1, _ := originals.Hash(bytes.NewReader(d1)) + Expect(store.Write(h1, "image/jpeg", bytes.NewReader(d1))).To(Succeed()) + d2 := []byte("orphan") + h2, _ := originals.Hash(bytes.NewReader(d2)) + Expect(store.Write(h2, "image/jpeg", bytes.NewReader(d2))).To(Succeed()) + + removed, err := store.Sweep(func(h string) bool { return h == h1 }) + Expect(err).ToNot(HaveOccurred()) + Expect(removed).To(Equal(1)) + _, err = store.Open(h2, "image/jpeg") + Expect(os.IsNotExist(err)).To(BeTrue()) + rc, err := store.Open(h1, "image/jpeg") + Expect(err).ToNot(HaveOccurred()) + rc.Close() + }) +}) diff --git a/go.mod b/go.mod index 5488b41e4..b72f4035c 100644 --- a/go.mod +++ b/go.mod @@ -57,6 +57,7 @@ require ( github.com/tetratelabs/wazero v1.12.0 github.com/unrolled/secure v1.17.0 github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 + github.com/zeebo/xxh3 v1.1.0 go.senan.xyz/taglib v0.11.1 go.uber.org/goleak v1.3.0 golang.org/x/image v0.44.0 @@ -128,7 +129,6 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect github.com/valyala/fastjson v1.6.10 // indirect - github.com/zeebo/xxh3 v1.1.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect From db16b3de9a6f6eb5f0fe8087fc41e9a45e4329c4 Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 21 Jul 2026 22:58:20 -0400 Subject: [PATCH 07/87] feat(artwork): add artwork prune (orphan cleanup) --- core/artwork/prune.go | 48 ++++++++++++++++++++++++++++++ core/artwork/prune_test.go | 61 ++++++++++++++++++++++++++++++++++++++ tests/mock_artwork_repo.go | 7 +++-- 3 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 core/artwork/prune.go create mode 100644 core/artwork/prune_test.go diff --git a/core/artwork/prune.go b/core/artwork/prune.go new file mode 100644 index 000000000..7d66bd8fe --- /dev/null +++ b/core/artwork/prune.go @@ -0,0 +1,48 @@ +package artwork + +import ( + "context" + "time" + + "github.com/navidrome/navidrome/core/artwork/originals" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +// pruneMinAge guards the window between artwork insert and item_artwork upsert. +const pruneMinAge = time.Hour + +func Prune(ctx context.Context, ds model.DataStore, store *originals.Store) error { + repo := ds.Artwork(ctx) + orphans, err := repo.GetOrphanHashes(time.Now().Add(-pruneMinAge)) + if err != nil { + return err + } + if len(orphans) > 0 { + arts, err := repo.GetBatch(orphans) + if err != nil { + return err + } + if err := repo.Delete(orphans...); err != nil { + return err + } + for _, a := range arts { + if err := store.Remove(a.Hash, a.Mime); err != nil { + log.Warn(ctx, "Prune: could not remove artwork file", "hash", a.Hash, err) + } + } + log.Info(ctx, "Prune: removed orphan artwork", "count", len(orphans)) + } + + removed, err := store.Sweep(func(hash string) bool { + _, err := repo.Get(hash) + return err == nil + }) + if err != nil { + return err + } + if removed > 0 { + log.Info(ctx, "Prune: swept stray artwork files", "count", removed) + } + return nil +} diff --git a/core/artwork/prune_test.go b/core/artwork/prune_test.go new file mode 100644 index 000000000..ae6901da4 --- /dev/null +++ b/core/artwork/prune_test.go @@ -0,0 +1,61 @@ +package artwork + +import ( + "bytes" + "context" + "os" + "time" + + "github.com/navidrome/navidrome/core/artwork/originals" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Prune", func() { + var ds *tests.MockDataStore + var store *originals.Store + var awRepo *tests.MockArtworkRepo + + BeforeEach(func() { + ds = &tests.MockDataStore{} + awRepo = ds.Artwork(context.Background()).(*tests.MockArtworkRepo) + store = originals.New(GinkgoT().TempDir()) + }) + + It("deletes orphan rows and their store files, keeps referenced ones", func() { + data := []byte("orphan-bytes") + h, _ := originals.Hash(bytes.NewReader(data)) + Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) + Expect(awRepo.Put(&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, _ := originals.Hash(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(Prune(context.Background(), ds, store)).To(Succeed()) + + _, err := awRepo.Get(h) + Expect(err).To(MatchError(model.ErrNotFound)) + _, err = store.Open(h, "image/jpeg") + Expect(os.IsNotExist(err)).To(BeTrue()) + rc, err := store.Open(hk, "image/jpeg") + Expect(err).ToNot(HaveOccurred()) + rc.Close() + }) + + It("sweeps store files that have no artwork row", func() { + stray := []byte("no-row-bytes") + h, _ := originals.Hash(bytes.NewReader(stray)) + Expect(store.Write(h, "image/jpeg", bytes.NewReader(stray))).To(Succeed()) + + Expect(Prune(context.Background(), ds, store)).To(Succeed()) + + _, err := store.Open(h, "image/jpeg") + Expect(os.IsNotExist(err)).To(BeTrue()) + }) +}) diff --git a/tests/mock_artwork_repo.go b/tests/mock_artwork_repo.go index c8b95a025..a30ffe97b 100644 --- a/tests/mock_artwork_repo.go +++ b/tests/mock_artwork_repo.go @@ -8,8 +8,9 @@ import ( type MockArtworkRepo struct { model.ArtworkRepository - Data map[string]model.Artwork - Err error + Data map[string]model.Artwork + OrphanHashes []string + Err error } func CreateMockArtworkRepo() *MockArtworkRepo { @@ -51,7 +52,7 @@ func (m *MockArtworkRepo) GetBatch(hashes []string) (map[string]model.Artwork, e } func (m *MockArtworkRepo) GetOrphanHashes(createdBefore time.Time) ([]string, error) { - return nil, m.Err + return m.OrphanHashes, m.Err } func (m *MockArtworkRepo) Delete(hashes ...string) error { From 3e7685adc23be7235d6d3c79ef2b48b683d74dfa Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 21 Jul 2026 23:02:47 -0400 Subject: [PATCH 08/87] fix(artwork): never sweep files on transient DB errors during prune --- core/artwork/prune.go | 3 ++- core/artwork/prune_test.go | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/core/artwork/prune.go b/core/artwork/prune.go index 7d66bd8fe..931990056 100644 --- a/core/artwork/prune.go +++ b/core/artwork/prune.go @@ -2,6 +2,7 @@ package artwork import ( "context" + "errors" "time" "github.com/navidrome/navidrome/core/artwork/originals" @@ -36,7 +37,7 @@ func Prune(ctx context.Context, ds model.DataStore, store *originals.Store) erro removed, err := store.Sweep(func(hash string) bool { _, err := repo.Get(hash) - return err == nil + return !errors.Is(err, model.ErrNotFound) }) if err != nil { return err diff --git a/core/artwork/prune_test.go b/core/artwork/prune_test.go index ae6901da4..54564a170 100644 --- a/core/artwork/prune_test.go +++ b/core/artwork/prune_test.go @@ -3,6 +3,7 @@ package artwork import ( "bytes" "context" + "errors" "os" "time" @@ -13,6 +14,14 @@ import ( . "github.com/onsi/gomega" ) +type flakyGetArtworkRepo struct { + *tests.MockArtworkRepo +} + +func (f *flakyGetArtworkRepo) Get(string) (*model.Artwork, error) { + return nil, errors.New("db locked") +} + var _ = Describe("Prune", func() { var ds *tests.MockDataStore var store *originals.Store @@ -58,4 +67,18 @@ var _ = Describe("Prune", func() { _, err := store.Open(h, "image/jpeg") Expect(os.IsNotExist(err)).To(BeTrue()) }) + + It("never sweeps files on a transient DB error", func() { + ds.MockedArtwork = &flakyGetArtworkRepo{MockArtworkRepo: tests.CreateMockArtworkRepo()} + + data := []byte("live-bytes") + h, _ := originals.Hash(bytes.NewReader(data)) + Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) + + Expect(Prune(context.Background(), ds, store)).To(Succeed()) + + rc, err := store.Open(h, "image/jpeg") + Expect(err).ToNot(HaveOccurred()) + rc.Close() + }) }) From b16ef725c90bb40bdf69a8dc7229fc1c47314450 Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 21 Jul 2026 23:09:31 -0400 Subject: [PATCH 09/87] refactor(artwork): fold originals package into core/artwork as ImageStore --- .../originals.go => image_store.go} | 24 +++++++++---------- .../originals_test.go => image_store_test.go} | 23 +++++++++--------- .../artwork/originals/originals_suite_test.go | 17 ------------- core/artwork/prune.go | 3 +-- core/artwork/prune_test.go | 13 +++++----- 5 files changed, 30 insertions(+), 50 deletions(-) rename core/artwork/{originals/originals.go => image_store.go} (74%) rename core/artwork/{originals/originals_test.go => image_store_test.go} (76%) delete mode 100644 core/artwork/originals/originals_suite_test.go diff --git a/core/artwork/originals/originals.go b/core/artwork/image_store.go similarity index 74% rename from core/artwork/originals/originals.go rename to core/artwork/image_store.go index 12cfe56cc..c98064d13 100644 --- a/core/artwork/originals/originals.go +++ b/core/artwork/image_store.go @@ -1,6 +1,4 @@ -// Package originals is the content-addressed store for artwork images that have -// no library file backing them (external downloads, embedded extractions, generated). -package originals +package artwork import ( "errors" @@ -14,7 +12,7 @@ import ( "github.com/zeebo/xxh3" ) -func Hash(r io.Reader) (string, error) { +func HashImage(r io.Reader) (string, error) { d := xxh3.New() if _, err := io.Copy(d, r); err != nil { return "", err @@ -22,12 +20,14 @@ func Hash(r io.Reader) (string, error) { return fmt.Sprintf("%016x", d.Sum64()), nil } -type Store struct { +// ImageStore is the content-addressed store for artwork images that have no +// library file backing them (external downloads, embedded extractions, generated). +type ImageStore struct { root string } -func New(rootDir string) *Store { - return &Store{root: rootDir} +func NewImageStore(rootDir string) *ImageStore { + return &ImageStore{root: rootDir} } // extForMime is deliberately NOT mime.ExtensionsByType: extensions are baked into @@ -46,11 +46,11 @@ func extForMime(m string) string { return ".img" } -func (s *Store) path(hash, mimeType string) string { +func (s *ImageStore) path(hash, mimeType string) string { return filepath.Join(s.root, hash[0:2], hash[2:4], hash+extForMime(mimeType)) } -func (s *Store) Write(hash, mimeType string, r io.Reader) error { +func (s *ImageStore) Write(hash, mimeType string, r io.Reader) error { dst := s.path(hash, mimeType) if _, err := os.Stat(dst); err == nil { return nil @@ -73,11 +73,11 @@ func (s *Store) Write(hash, mimeType string, r io.Reader) error { return os.Rename(tmp.Name(), dst) } -func (s *Store) Open(hash, mimeType string) (io.ReadCloser, error) { +func (s *ImageStore) Open(hash, mimeType string) (io.ReadCloser, error) { return os.Open(s.path(hash, mimeType)) } -func (s *Store) Remove(hash, mimeType string) error { +func (s *ImageStore) Remove(hash, mimeType string) error { err := os.Remove(s.path(hash, mimeType)) if errors.Is(err, fs.ErrNotExist) { return nil @@ -85,7 +85,7 @@ func (s *Store) Remove(hash, mimeType string) error { return err } -func (s *Store) Sweep(keep func(hash string) bool) (int, error) { +func (s *ImageStore) Sweep(keep func(hash string) bool) (int, error) { removed := 0 err := filepath.WalkDir(s.root, func(path string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { diff --git a/core/artwork/originals/originals_test.go b/core/artwork/image_store_test.go similarity index 76% rename from core/artwork/originals/originals_test.go rename to core/artwork/image_store_test.go index caa861a6f..c3ca69a58 100644 --- a/core/artwork/originals/originals_test.go +++ b/core/artwork/image_store_test.go @@ -1,4 +1,4 @@ -package originals_test +package artwork import ( "bytes" @@ -6,33 +6,32 @@ import ( "os" "path/filepath" - "github.com/navidrome/navidrome/core/artwork/originals" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) -var _ = Describe("Originals Store", func() { - var store *originals.Store +var _ = Describe("ImageStore", func() { + var store *ImageStore var root string BeforeEach(func() { root = GinkgoT().TempDir() - store = originals.New(root) + store = NewImageStore(root) }) It("hashes deterministically", func() { - h1, err := originals.Hash(bytes.NewReader([]byte("some image bytes"))) + h1, err := HashImage(bytes.NewReader([]byte("some image bytes"))) Expect(err).ToNot(HaveOccurred()) - h2, _ := originals.Hash(bytes.NewReader([]byte("some image bytes"))) + h2, _ := HashImage(bytes.NewReader([]byte("some image bytes"))) Expect(h1).To(Equal(h2)) Expect(h1).To(HaveLen(16)) - h3, _ := originals.Hash(bytes.NewReader([]byte("other bytes"))) + h3, _ := HashImage(bytes.NewReader([]byte("other bytes"))) Expect(h3).ToNot(Equal(h1)) }) It("writes sharded and reads back", func() { data := []byte("jpeg-bytes") - h, _ := originals.Hash(bytes.NewReader(data)) + h, _ := HashImage(bytes.NewReader(data)) Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) Expect(filepath.Join(root, h[0:2], h[2:4], h+".jpg")).To(BeAnExistingFile()) @@ -46,7 +45,7 @@ var _ = Describe("Originals Store", func() { It("is idempotent on duplicate writes", func() { data := []byte("dup") - h, _ := originals.Hash(bytes.NewReader(data)) + h, _ := HashImage(bytes.NewReader(data)) Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed()) Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed()) }) @@ -62,10 +61,10 @@ var _ = Describe("Originals Store", func() { It("sweeps unknown files, keeps known ones", func() { d1 := []byte("keep-me") - h1, _ := originals.Hash(bytes.NewReader(d1)) + h1, _ := HashImage(bytes.NewReader(d1)) Expect(store.Write(h1, "image/jpeg", bytes.NewReader(d1))).To(Succeed()) d2 := []byte("orphan") - h2, _ := originals.Hash(bytes.NewReader(d2)) + h2, _ := HashImage(bytes.NewReader(d2)) Expect(store.Write(h2, "image/jpeg", bytes.NewReader(d2))).To(Succeed()) removed, err := store.Sweep(func(h string) bool { return h == h1 }) diff --git a/core/artwork/originals/originals_suite_test.go b/core/artwork/originals/originals_suite_test.go deleted file mode 100644 index 2686f9ec9..000000000 --- a/core/artwork/originals/originals_suite_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package originals_test - -import ( - "testing" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestOriginals(t *testing.T) { - tests.Init(t, false) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "Originals Store Suite") -} diff --git a/core/artwork/prune.go b/core/artwork/prune.go index 931990056..9c88611a3 100644 --- a/core/artwork/prune.go +++ b/core/artwork/prune.go @@ -5,7 +5,6 @@ import ( "errors" "time" - "github.com/navidrome/navidrome/core/artwork/originals" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" ) @@ -13,7 +12,7 @@ import ( // pruneMinAge guards the window between artwork insert and item_artwork upsert. const pruneMinAge = time.Hour -func Prune(ctx context.Context, ds model.DataStore, store *originals.Store) error { +func Prune(ctx context.Context, ds model.DataStore, store *ImageStore) error { repo := ds.Artwork(ctx) orphans, err := repo.GetOrphanHashes(time.Now().Add(-pruneMinAge)) if err != nil { diff --git a/core/artwork/prune_test.go b/core/artwork/prune_test.go index 54564a170..925e46fd1 100644 --- a/core/artwork/prune_test.go +++ b/core/artwork/prune_test.go @@ -7,7 +7,6 @@ import ( "os" "time" - "github.com/navidrome/navidrome/core/artwork/originals" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" @@ -24,25 +23,25 @@ func (f *flakyGetArtworkRepo) Get(string) (*model.Artwork, error) { var _ = Describe("Prune", func() { var ds *tests.MockDataStore - var store *originals.Store + var store *ImageStore var awRepo *tests.MockArtworkRepo BeforeEach(func() { ds = &tests.MockDataStore{} awRepo = ds.Artwork(context.Background()).(*tests.MockArtworkRepo) - store = originals.New(GinkgoT().TempDir()) + store = NewImageStore(GinkgoT().TempDir()) }) It("deletes orphan rows and their store files, keeps referenced ones", func() { data := []byte("orphan-bytes") - h, _ := originals.Hash(bytes.NewReader(data)) + 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", CreatedAt: time.Now().Add(-2 * time.Hour)})).To(Succeed()) awRepo.OrphanHashes = []string{h} kept := []byte("kept-bytes") - hk, _ := originals.Hash(bytes.NewReader(kept)) + 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()) @@ -59,7 +58,7 @@ var _ = Describe("Prune", func() { It("sweeps store files that have no artwork row", func() { stray := []byte("no-row-bytes") - h, _ := originals.Hash(bytes.NewReader(stray)) + h, _ := HashImage(bytes.NewReader(stray)) Expect(store.Write(h, "image/jpeg", bytes.NewReader(stray))).To(Succeed()) Expect(Prune(context.Background(), ds, store)).To(Succeed()) @@ -72,7 +71,7 @@ var _ = Describe("Prune", func() { ds.MockedArtwork = &flakyGetArtworkRepo{MockArtworkRepo: tests.CreateMockArtworkRepo()} data := []byte("live-bytes") - h, _ := originals.Hash(bytes.NewReader(data)) + h, _ := HashImage(bytes.NewReader(data)) Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) Expect(Prune(context.Background(), ds, store)).To(Succeed()) From 4f835437a9787f9900fc875b8d11056d49bb1c64 Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 21 Jul 2026 23:14:01 -0400 Subject: [PATCH 10/87] refactor(artwork): merge item artwork state into ArtworkRepository --- core/artwork/prune.go | 6 +- core/artwork/prune_test.go | 8 +- model/artwork.go | 20 ++-- model/datastore.go | 1 - persistence/artwork_repository.go | 79 ++++++++++++++- persistence/artwork_repository_test.go | 102 +++++++++++++++++--- persistence/item_artwork_repository.go | 93 ------------------ persistence/item_artwork_repository_test.go | 83 ---------------- persistence/persistence.go | 4 - tests/mock_artwork_repo.go | 61 +++++++++++- tests/mock_data_store.go | 12 --- tests/mock_item_artwork_repo.go | 67 ------------- 12 files changed, 232 insertions(+), 304 deletions(-) delete mode 100644 persistence/item_artwork_repository.go delete mode 100644 persistence/item_artwork_repository_test.go delete mode 100644 tests/mock_item_artwork_repo.go 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 -} From 1041e45ca7bfdbe04a8db61f87464ef9713792a6 Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 21 Jul 2026 23:24:42 -0400 Subject: [PATCH 11/87] fix(artwork): chunk unbounded IN clauses and restore interface docs --- model/artwork.go | 15 ++++-- persistence/artwork_repository.go | 71 +++++++++++++------------- persistence/artwork_repository_test.go | 15 ++++++ 3 files changed, 61 insertions(+), 40 deletions(-) diff --git a/model/artwork.go b/model/artwork.go index 8fe2bc7fe..6507c24d7 100644 --- a/model/artwork.go +++ b/model/artwork.go @@ -19,11 +19,13 @@ 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"` + ItemKind string `structs:"item_kind"` + ItemID string `structs:"item_id"` + ImageType string `structs:"image_type"` + Hash string `structs:"hash"` + Source string `structs:"source"` + // attempted_at/updated_at are nullable in the schema but always set by PutItemArtwork; + // raw inserts must set them too, since these non-pointer time.Time fields fail to scan NULL. AttemptedAt time.Time `structs:"attempted_at"` UpdatedAt time.Time `structs:"updated_at"` } @@ -59,13 +61,16 @@ type ArtworkRepository interface { GetImage(hash string) (*Artwork, error) PutImage(a *Artwork) error GetImages(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) 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/persistence/artwork_repository.go b/persistence/artwork_repository.go index 839a390ed..4556bc588 100644 --- a/persistence/artwork_repository.go +++ b/persistence/artwork_repository.go @@ -2,6 +2,7 @@ package persistence import ( "context" + "slices" "time" . "github.com/Masterminds/squirrel" @@ -47,16 +48,15 @@ func (r *artworkRepository) PutImage(a *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 - } - sel := Select("*").From(r.tableName).Where(Eq{"hash": hashes}) - var all []model.Artwork - if err := r.queryAll(sel, &all); err != nil { - return nil, err - } - for _, a := range all { - res[a.Hash] = a + for chunk := range slices.Chunk(hashes, 200) { + sel := Select("*").From(r.tableName).Where(Eq{"hash": chunk}) + var all []model.Artwork + if err := r.queryAll(sel, &all); err != nil { + return nil, err + } + for _, a := range all { + res[a.Hash] = a + } } return res, nil } @@ -73,10 +73,12 @@ func (r *artworkRepository) GetOrphanHashes(createdBefore time.Time) ([]string, } func (r *artworkRepository) DeleteImages(hashes ...string) error { - if len(hashes) == 0 { - return nil + for chunk := range slices.Chunk(hashes, 200) { + if err := r.delete(Eq{"hash": chunk}); err != nil { + return err + } } - return r.delete(Eq{"hash": hashes}) + return nil } func (r *artworkRepository) GetItemArtwork(kind, id, imageType string) (*model.ItemArtwork, error) { @@ -113,28 +115,27 @@ func (r *artworkRepository) DeleteForItem(kind, id string) error { 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 == "", + for chunk := range slices.Chunk(ids, 200) { + 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": chunk}, + }) + 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 diff --git a/persistence/artwork_repository_test.go b/persistence/artwork_repository_test.go index b4da4ebbe..a77fd7038 100644 --- a/persistence/artwork_repository_test.go +++ b/persistence/artwork_repository_test.go @@ -2,6 +2,7 @@ package persistence import ( "context" + "fmt" "time" "github.com/navidrome/navidrome/model" @@ -81,6 +82,20 @@ var _ = Describe("ArtworkRepository", func() { _, err := repo.GetImage("d1") Expect(err).To(MatchError(model.ErrNotFound)) }) + + It("fetches a batch larger than the SQL variable limit", func() { + hashes := make([]string, 0, 250) + for i := 0; i < 250; i++ { + h := fmt.Sprintf("big%03d", i) + Expect(repo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed()) + hashes = append(hashes, h) + } + hashes = append(hashes, "absent1", "absent2") + + got, err := repo.GetImages(hashes) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(HaveLen(250)) + }) }) var _ = Describe("ArtworkRepository item state", func() { From 8fd7ef19f385640206cf5b8c57c434fe9bffc080 Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 21 Jul 2026 23:37:05 -0400 Subject: [PATCH 12/87] refactor(artwork): apply simplify-pass cleanups Internal item_artwork sqlRepository helper, toSQLArgs upserts, batched queue enqueue, EnqueueStaleAbsent moved to queue repo, snapshot-based prune sweep, mock/real semantics aligned. --- core/artwork/prune.go | 13 +- core/artwork/prune_test.go | 4 +- model/artwork.go | 10 +- persistence/artwork_queue_repository.go | 42 ++-- persistence/artwork_queue_repository_test.go | 18 ++ persistence/artwork_repository.go | 76 +++--- persistence/artwork_repository_test.go | 247 +++++++++---------- tests/mock_artwork_queue_repo.go | 27 +- tests/mock_artwork_repo.go | 26 +- 9 files changed, 260 insertions(+), 203 deletions(-) diff --git a/core/artwork/prune.go b/core/artwork/prune.go index 11d9ce34f..ebbb0bbc4 100644 --- a/core/artwork/prune.go +++ b/core/artwork/prune.go @@ -2,7 +2,6 @@ package artwork import ( "context" - "errors" "time" "github.com/navidrome/navidrome/log" @@ -34,9 +33,17 @@ func Prune(ctx context.Context, ds model.DataStore, store *ImageStore) error { log.Info(ctx, "Prune: removed orphan artwork", "count", len(orphans)) } + hashes, err := repo.GetAllHashes() + if err != nil { + return err + } + known := make(map[string]struct{}, len(hashes)) + for _, h := range hashes { + known[h] = struct{}{} + } removed, err := store.Sweep(func(hash string) bool { - _, err := repo.GetImage(hash) - return !errors.Is(err, model.ErrNotFound) + _, ok := known[hash] + return ok }) if err != nil { return err diff --git a/core/artwork/prune_test.go b/core/artwork/prune_test.go index 68211879d..2c8751674 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) GetImage(string) (*model.Artwork, error) { +func (f *flakyGetArtworkRepo) GetAllHashes() ([]string, error) { return nil, errors.New("db locked") } @@ -74,7 +74,7 @@ var _ = Describe("Prune", func() { h, _ := HashImage(bytes.NewReader(data)) Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) - Expect(Prune(context.Background(), ds, store)).To(Succeed()) + Expect(Prune(context.Background(), ds, store)).ToNot(Succeed()) rc, err := store.Open(h, "image/jpeg") Expect(err).ToNot(HaveOccurred()) diff --git a/model/artwork.go b/model/artwork.go index 6507c24d7..986a5c65d 100644 --- a/model/artwork.go +++ b/model/artwork.go @@ -35,9 +35,11 @@ type ItemArtworkInfo struct { ItemID string Hash string BlurHash string - Absent bool } +// Absent reports a known-absent artwork state (resolved, no image). +func (i ItemArtworkInfo) Absent() bool { return i.Hash == "" } + type ArtworkQueueItem struct { ItemKind string `structs:"item_kind"` ItemID string `structs:"item_id"` @@ -70,8 +72,8 @@ type ArtworkRepository interface { 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) + // GetAllHashes returns every stored artwork hash, for sweep membership checks. + GetAllHashes() ([]string, error) } type ArtworkQueueRepository interface { @@ -83,4 +85,6 @@ type ArtworkQueueRepository interface { MarkFailed(kind, id, imageType string, retryAt time.Time) error Delete(kind, id, imageType string) error Count() (int64, error) + // EnqueueStaleAbsent inserts queue rows (priority Recheck) for absent states older than cutoff. + EnqueueStaleAbsent(kind string, attemptedBefore time.Time) (int64, error) } diff --git a/persistence/artwork_queue_repository.go b/persistence/artwork_queue_repository.go index fdf88db24..ced5e9913 100644 --- a/persistence/artwork_queue_repository.go +++ b/persistence/artwork_queue_repository.go @@ -2,6 +2,7 @@ package persistence import ( "context" + "slices" "time" . "github.com/Masterminds/squirrel" @@ -9,6 +10,9 @@ import ( "github.com/pocketbase/dbx" ) +// enqueueChunkSize keeps each multi-row insert under SQLite's bind-variable limit (7 cols -> 700 vars). +const enqueueChunkSize = 100 + type artworkQueueRepository struct { sqlRepository } @@ -23,14 +27,15 @@ func NewArtworkQueueRepository(ctx context.Context, db dbx.Builder) model.Artwor func (r *artworkQueueRepository) Enqueue(items ...model.ArtworkQueueItem) error { now := time.Now() - for _, it := range items { - if it.ImageType == "" { - it.ImageType = model.ImageTypePrimary + for chunk := range slices.Chunk(items, enqueueChunkSize) { + ins := Insert(r.tableName).Columns("item_kind", "item_id", "image_type", "priority", "attempts", "retry_at", "enqueued_at") + for _, it := range chunk { + if it.ImageType == "" { + it.ImageType = model.ImageTypePrimary + } + ins = ins.Values(it.ItemKind, it.ItemID, it.ImageType, it.Priority, 0, now, now) } - ins := Insert(r.tableName).SetMap(map[string]any{ - "item_kind": it.ItemKind, "item_id": it.ItemID, "image_type": it.ImageType, - "priority": it.Priority, "attempts": 0, "retry_at": now, "enqueued_at": now, - }).Suffix(`ON CONFLICT (item_kind, item_id, image_type) DO UPDATE SET + ins = ins.Suffix(`ON CONFLICT (item_kind, item_id, image_type) DO UPDATE SET priority = MAX(priority, excluded.priority), retry_at = excluded.retry_at`) if _, err := r.executeSQL(ins); err != nil { return err @@ -66,15 +71,20 @@ func (r *artworkQueueRepository) Delete(kind, id, imageType string) error { } func (r *artworkQueueRepository) Count() (int64, error) { - sel := Select("count(*)").From(r.tableName) - var counts []int64 - if err := r.queryAllSlice(sel, &counts); err != nil { - return 0, err - } - if len(counts) == 0 { - return 0, nil - } - return counts[0], nil + var res struct{ Count int64 } + err := r.queryOne(Select("count(*) as count").From(r.tableName), &res) + return res.Count, err +} + +func (r *artworkQueueRepository) EnqueueStaleAbsent(kind string, attemptedBefore time.Time) (int64, error) { + now := time.Now() + // DO NOTHING is deliberate: rechecks must not bump priority/retry_at of already-queued items. + ins := Expr(`INSERT INTO `+r.tableName+` (item_kind, item_id, image_type, priority, attempts, retry_at, enqueued_at) + SELECT item_kind, item_id, image_type, ?, 0, ?, ? + FROM `+itemArtworkTable+` 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.ArtworkQueueRepository = (*artworkQueueRepository)(nil) diff --git a/persistence/artwork_queue_repository_test.go b/persistence/artwork_queue_repository_test.go index 73665b07c..2b6f0d8c5 100644 --- a/persistence/artwork_queue_repository_test.go +++ b/persistence/artwork_queue_repository_test.go @@ -62,4 +62,22 @@ var _ = Describe("ArtworkQueueRepository", func() { n, _ = repo.Count() Expect(n).To(BeZero()) }) + + It("enqueues stale absent states for recheck", func() { + awRepo := NewArtworkRepository(context.Background(), GetDBXBuilder()) + old := time.Now().Add(-48 * time.Hour) + Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "stale1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old})).To(Succeed()) + Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "fresh1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()})).To(Succeed()) + Expect(awRepo.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))) + + items, err := repo.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/artwork_repository.go b/persistence/artwork_repository.go index 4556bc588..c87836ca9 100644 --- a/persistence/artwork_repository.go +++ b/persistence/artwork_repository.go @@ -10,8 +10,18 @@ import ( "github.com/pocketbase/dbx" ) +const ( + itemArtworkTable = "item_artwork" + artworkBatchSize = 200 +) + +type itemArtworkSQL struct { + sqlRepository +} + type artworkRepository struct { sqlRepository + items itemArtworkSQL } func NewArtworkRepository(ctx context.Context, db dbx.Builder) model.ArtworkRepository { @@ -19,6 +29,9 @@ func NewArtworkRepository(ctx context.Context, db dbx.Builder) model.ArtworkRepo r.ctx = ctx r.db = db r.tableName = "artwork" + r.items.ctx = ctx + r.items.db = db + r.items.tableName = itemArtworkTable return r } @@ -35,20 +48,20 @@ func (r *artworkRepository) PutImage(a *model.Artwork) error { if a.CreatedAt.IsZero() { a.CreatedAt = time.Now() } - ins := Insert(r.tableName).SetMap(map[string]any{ - "hash": a.Hash, "mime": a.Mime, "width": a.Width, "height": a.Height, - "size_bytes": a.SizeBytes, "blur_hash": a.BlurHash, - "source_path": a.SourcePath, "ref_mtime": a.RefMtime, "created_at": a.CreatedAt, - }).Suffix(`ON CONFLICT (hash) DO UPDATE SET mime=excluded.mime, width=excluded.width, + values, err := toSQLArgs(*a) + if err != nil { + return err + } + ins := Insert(r.tableName).SetMap(values).Suffix(`ON CONFLICT (hash) DO UPDATE SET mime=excluded.mime, width=excluded.width, height=excluded.height, size_bytes=excluded.size_bytes, blur_hash=excluded.blur_hash, source_path=excluded.source_path, ref_mtime=excluded.ref_mtime`) - _, err := r.executeSQL(ins) + _, err = r.executeSQL(ins) return err } func (r *artworkRepository) GetImages(hashes []string) (map[string]model.Artwork, error) { res := map[string]model.Artwork{} - for chunk := range slices.Chunk(hashes, 200) { + for chunk := range slices.Chunk(hashes, artworkBatchSize) { sel := Select("*").From(r.tableName).Where(Eq{"hash": chunk}) var all []model.Artwork if err := r.queryAll(sel, &all); err != nil { @@ -61,11 +74,18 @@ func (r *artworkRepository) GetImages(hashes []string) (map[string]model.Artwork return res, nil } +func (r *artworkRepository) GetAllHashes() ([]string, error) { + sel := Select("hash").From(r.tableName) + var hashes []string + err := r.queryAllSlice(sel, &hashes) + return hashes, err +} + func (r *artworkRepository) GetOrphanHashes(createdBefore time.Time) ([]string, error) { sel := Select("hash").From(r.tableName). Where(And{ Lt{"created_at": createdBefore}, - Expr("hash NOT IN (SELECT hash FROM item_artwork WHERE hash <> '')"), + Expr("hash NOT IN (SELECT hash FROM " + itemArtworkTable + " WHERE hash <> '')"), }) var hashes []string err := r.queryAllSlice(sel, &hashes) @@ -73,7 +93,7 @@ func (r *artworkRepository) GetOrphanHashes(createdBefore time.Time) ([]string, } func (r *artworkRepository) DeleteImages(hashes ...string) error { - for chunk := range slices.Chunk(hashes, 200) { + for chunk := range slices.Chunk(hashes, artworkBatchSize) { if err := r.delete(Eq{"hash": chunk}); err != nil { return err } @@ -82,10 +102,10 @@ func (r *artworkRepository) DeleteImages(hashes ...string) error { } func (r *artworkRepository) GetItemArtwork(kind, id, imageType string) (*model.ItemArtwork, error) { - sel := Select("*").From("item_artwork"). + sel := Select("*").From(itemArtworkTable). Where(Eq{"item_kind": kind, "item_id": id, "image_type": imageType}) var res model.ItemArtwork - if err := r.queryOne(sel, &res); err != nil { + if err := r.items.queryOne(sel, &res); err != nil { return nil, err } return &res, nil @@ -96,28 +116,26 @@ func (r *artworkRepository) PutItemArtwork(ia *model.ItemArtwork) error { 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 + values, err := toSQLArgs(*ia) + if err != nil { + return err + } + ins := Insert(itemArtworkTable).SetMap(values).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) + _, err = r.items.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 + return r.items.delete(Eq{"item_kind": kind, "item_id": id}) } func (r *artworkRepository) GetInfoForItems(kind string, ids []string) (map[string]model.ItemArtworkInfo, error) { res := map[string]model.ItemArtworkInfo{} - for chunk := range slices.Chunk(ids, 200) { + for chunk := range slices.Chunk(ids, artworkBatchSize) { sel := Select("ia.item_id", "ia.hash", "COALESCE(a.blur_hash, '') as blur_hash"). - From("item_artwork ia"). + From(itemArtworkTable + " ia"). LeftJoin("artwork a ON a.hash = ia.hash"). Where(And{ Eq{"ia.item_kind": kind}, @@ -129,26 +147,16 @@ func (r *artworkRepository) GetInfoForItems(kind string, ids []string) (map[stri Hash string BlurHash string } - if err := r.queryAll(sel, &rows); err != nil { + if err := r.items.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 == "", + ItemID: row.ItemID, Hash: row.Hash, BlurHash: row.BlurHash, } } } 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 a77fd7038..0fdb122b6 100644 --- a/persistence/artwork_repository_test.go +++ b/persistence/artwork_repository_test.go @@ -27,144 +27,129 @@ var _ = Describe("ArtworkRepository", func() { repo = NewArtworkRepository(context.Background(), GetDBXBuilder()) }) - 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.PutImage(a)).To(Succeed()) + Context("image identity", 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.PutImage(a)).To(Succeed()) - 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")) - Expect(got.CreatedAt).ToNot(BeZero()) + 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")) + Expect(got.CreatedAt).ToNot(BeZero()) + }) + + It("is idempotent on Put (upsert by hash)", func() { + a := &model.Artwork{Hash: "dup1", Mime: "image/png"} + Expect(repo.PutImage(a)).To(Succeed()) + a.BlurHash = "XYZ" + 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.GetImage("nope") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("fetches a batch", func() { + 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("returns every stored hash", func() { + Expect(repo.PutImage(&model.Artwork{Hash: "all1", Mime: "image/jpeg"})).To(Succeed()) + Expect(repo.PutImage(&model.Artwork{Hash: "all2", Mime: "image/png"})).To(Succeed()) + hashes, err := repo.GetAllHashes() + Expect(err).ToNot(HaveOccurred()) + Expect(hashes).To(ConsistOf("all1", "all2")) + }) + + It("finds orphans older than cutoff, honoring item_artwork references", func() { + 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()) + Expect(orphans).To(ContainElement("orph1")) + Expect(orphans).ToNot(ContainElement("ref1")) + + orphans, err = repo.GetOrphanHashes(time.Now().Add(-time.Hour)) + Expect(err).ToNot(HaveOccurred()) + Expect(orphans).To(BeEmpty()) + }) + + It("deletes by hashes", func() { + 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)) + }) + + It("fetches a batch larger than the SQL variable limit", func() { + hashes := make([]string, 0, 250) + for i := range 250 { + h := fmt.Sprintf("big%03d", i) + Expect(repo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed()) + hashes = append(hashes, h) + } + hashes = append(hashes, "absent1", "absent2") + + got, err := repo.GetImages(hashes) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(HaveLen(250)) + }) }) - It("is idempotent on Put (upsert by hash)", func() { - a := &model.Artwork{Hash: "dup1", Mime: "image/png"} - Expect(repo.PutImage(a)).To(Succeed()) - a.BlurHash = "XYZ" - Expect(repo.PutImage(a)).To(Succeed()) - got, _ := repo.GetImage("dup1") - Expect(got.BlurHash).To(Equal("XYZ")) - }) + Context("item state", func() { + 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()) - It("returns ErrNotFound for a missing hash", func() { - _, err := repo.GetImage("nope") - Expect(err).To(MatchError(model.ErrNotFound)) - }) + got, err := repo.GetItemArtwork("al", "al1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(got.Source).To(Equal("embedded")) + Expect(got.UpdatedAt).ToNot(BeZero()) + }) - It("fetches a batch", func() { - 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("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("finds orphans older than cutoff, honoring item_artwork references", func() { - 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()) + 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()) - orphans, err := repo.GetOrphanHashes(time.Now().Add(time.Minute)) - Expect(err).ToNot(HaveOccurred()) - Expect(orphans).To(ContainElement("orph1")) - Expect(orphans).ToNot(ContainElement("ref1")) + 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()) + }) - orphans, err = repo.GetOrphanHashes(time.Now().Add(-time.Hour)) - Expect(err).ToNot(HaveOccurred()) - Expect(orphans).To(BeEmpty()) - }) - - It("deletes by hashes", func() { - 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)) - }) - - It("fetches a batch larger than the SQL variable limit", func() { - hashes := make([]string, 0, 250) - for i := 0; i < 250; i++ { - h := fmt.Sprintf("big%03d", i) - Expect(repo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed()) - hashes = append(hashes, h) - } - hashes = append(hashes, "absent1", "absent2") - - got, err := repo.GetImages(hashes) - Expect(err).ToNot(HaveOccurred()) - Expect(got).To(HaveLen(250)) - }) -}) - -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)) + 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)) + }) }) }) diff --git a/tests/mock_artwork_queue_repo.go b/tests/mock_artwork_queue_repo.go index b08a1297c..dcec7486d 100644 --- a/tests/mock_artwork_queue_repo.go +++ b/tests/mock_artwork_queue_repo.go @@ -9,7 +9,7 @@ import ( type MockArtworkQueueRepo struct { model.ArtworkQueueRepository - Data map[string]model.ArtworkQueueItem // key: kind + "|" + id + "|" + imageType + Data map[string]model.ArtworkQueueItem // keyed by iaKey(kind, id, imageType) Err error } @@ -21,16 +21,23 @@ func (m *MockArtworkQueueRepo) Enqueue(items ...model.ArtworkQueueItem) error { if m.Err != nil { return m.Err } + now := time.Now() 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.ImageType == "" { + it.ImageType = model.ImageTypePrimary } if it.RetryAt.IsZero() { - it.RetryAt = time.Now() + it.RetryAt = now + } + k := iaKey(it.ItemKind, it.ItemID, it.ImageType) + if prev, ok := m.Data[k]; ok { + prev.Priority = max(prev.Priority, it.Priority) + prev.RetryAt = it.RetryAt + m.Data[k] = prev + continue + } + if it.EnqueuedAt.IsZero() { + it.EnqueuedAt = now } m.Data[k] = it } @@ -89,3 +96,7 @@ func (m *MockArtworkQueueRepo) Count() (int64, error) { } return int64(len(m.Data)), nil } + +func (m *MockArtworkQueueRepo) EnqueueStaleAbsent(kind string, attemptedBefore time.Time) (int64, error) { + return 0, m.Err +} diff --git a/tests/mock_artwork_repo.go b/tests/mock_artwork_repo.go index 3989499ce..8ac4820e6 100644 --- a/tests/mock_artwork_repo.go +++ b/tests/mock_artwork_repo.go @@ -9,7 +9,7 @@ import ( type MockArtworkRepo struct { model.ArtworkRepository Data map[string]model.Artwork - ItemData map[string]model.ItemArtwork // key: kind + "|" + id + "|" + imageType + ItemData map[string]model.ItemArtwork // keyed by iaKey(kind, id, imageType) OrphanHashes []string Err error } @@ -58,6 +58,17 @@ func (m *MockArtworkRepo) GetOrphanHashes(createdBefore time.Time) ([]string, er return m.OrphanHashes, m.Err } +func (m *MockArtworkRepo) GetAllHashes() ([]string, error) { + if m.Err != nil { + return nil, m.Err + } + hashes := make([]string, 0, len(m.Data)) + for h := range m.Data { + hashes = append(hashes, h) + } + return hashes, nil +} + func (m *MockArtworkRepo) DeleteImages(hashes ...string) error { if m.Err != nil { return m.Err @@ -82,6 +93,9 @@ func (m *MockArtworkRepo) PutItemArtwork(ia *model.ItemArtwork) error { if m.Err != nil { return m.Err } + if ia.ImageType == "" { + ia.ImageType = model.ImageTypePrimary + } ia.UpdatedAt = time.Now() m.ItemData[iaKey(ia.ItemKind, ia.ItemID, ia.ImageType)] = *ia return nil @@ -106,12 +120,12 @@ func (m *MockArtworkRepo) GetInfoForItems(kind string, ids []string) (map[string 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 == ""} + info := model.ItemArtworkInfo{ItemID: id, Hash: ia.Hash} + if a, ok := m.Data[ia.Hash]; ok { + info.BlurHash = a.BlurHash + } + res[id] = info } } return res, nil } - -func (m *MockArtworkRepo) EnqueueStaleAbsent(kind string, attemptedBefore time.Time) (int64, error) { - return 0, m.Err -} From 6f7f9c64633e362e5f175ec475d76eb879752449 Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 21 Jul 2026 23:57:58 -0400 Subject: [PATCH 13/87] fix(artwork): address review findings on prune/sweep races and mock fidelity Sweep now honors an mtime grace window (in-flight acquisitions and temp files), reacquired orphans reset the prune grace window, and the queue mock implements real stale-absent semantics. --- core/artwork/image_store.go | 20 +++++++++++---- core/artwork/image_store_test.go | 35 +++++++++++++++++++++++++- core/artwork/prune.go | 7 ++++-- core/artwork/prune_test.go | 2 ++ persistence/artwork_repository.go | 8 +++--- persistence/artwork_repository_test.go | 14 +++++++++++ tests/mock_artwork_queue_repo.go | 27 +++++++++++++++++++- tests/mock_data_store.go | 6 ++++- 8 files changed, 105 insertions(+), 14 deletions(-) diff --git a/core/artwork/image_store.go b/core/artwork/image_store.go index c98064d13..517dfc879 100644 --- a/core/artwork/image_store.go +++ b/core/artwork/image_store.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/zeebo/xxh3" ) @@ -85,18 +86,27 @@ func (s *ImageStore) Remove(hash, mimeType string) error { return err } -func (s *ImageStore) Sweep(keep func(hash string) bool) (int, error) { +// Sweep removes store files not accepted by keep. Files modified after cutoff +// (including temp files) are always kept: their acquisition row may not be committed yet. +func (s *ImageStore) Sweep(cutoff time.Time, keep func(hash string) bool) (int, error) { removed := 0 err := filepath.WalkDir(s.root, func(path string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return err } - name := d.Name() - if strings.HasPrefix(name, ".") { // in-flight temp files + info, err := d.Info() + if err != nil { + return err + } + if info.ModTime().After(cutoff) { return nil } - hash := strings.TrimSuffix(name, filepath.Ext(name)) - if !keep(hash) { + name := d.Name() + remove := strings.HasPrefix(name, ".") // abandoned temp file past the grace window + if !remove { + remove = !keep(strings.TrimSuffix(name, filepath.Ext(name))) + } + if remove { // #nosec G122 -- path comes from WalkDir over our own store root, no attacker-controlled symlinks if err := os.Remove(path); err != nil { return err diff --git a/core/artwork/image_store_test.go b/core/artwork/image_store_test.go index c3ca69a58..2647f3b7f 100644 --- a/core/artwork/image_store_test.go +++ b/core/artwork/image_store_test.go @@ -5,6 +5,7 @@ import ( "io" "os" "path/filepath" + "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -67,7 +68,10 @@ var _ = Describe("ImageStore", func() { h2, _ := HashImage(bytes.NewReader(d2)) Expect(store.Write(h2, "image/jpeg", bytes.NewReader(d2))).To(Succeed()) - removed, err := store.Sweep(func(h string) bool { return h == h1 }) + old := time.Now().Add(-2 * time.Hour) + Expect(os.Chtimes(store.path(h2, "image/jpeg"), old, old)).To(Succeed()) + + removed, err := store.Sweep(time.Now().Add(-time.Hour), func(h string) bool { return h == h1 }) Expect(err).ToNot(HaveOccurred()) Expect(removed).To(Equal(1)) _, err = store.Open(h2, "image/jpeg") @@ -76,4 +80,33 @@ var _ = Describe("ImageStore", func() { Expect(err).ToNot(HaveOccurred()) rc.Close() }) + + It("keeps young unknown files inside the grace window", func() { + d := []byte("fresh-orphan") + h, _ := HashImage(bytes.NewReader(d)) + Expect(store.Write(h, "image/jpeg", bytes.NewReader(d))).To(Succeed()) + + removed, err := store.Sweep(time.Now().Add(-time.Hour), func(string) bool { return false }) + Expect(err).ToNot(HaveOccurred()) + Expect(removed).To(Equal(0)) + rc, err := store.Open(h, "image/jpeg") + Expect(err).ToNot(HaveOccurred()) + rc.Close() + }) + + It("removes abandoned temp files past the grace window, keeps fresh ones", func() { + oldTmp := filepath.Join(root, ".old.tmp") + Expect(os.WriteFile(oldTmp, []byte("x"), 0600)).To(Succeed()) + old := time.Now().Add(-2 * time.Hour) + Expect(os.Chtimes(oldTmp, old, old)).To(Succeed()) + + freshTmp := filepath.Join(root, ".fresh.tmp") + Expect(os.WriteFile(freshTmp, []byte("y"), 0600)).To(Succeed()) + + removed, err := store.Sweep(time.Now().Add(-time.Hour), func(string) bool { return true }) + Expect(err).ToNot(HaveOccurred()) + Expect(removed).To(Equal(1)) + Expect(oldTmp).ToNot(BeAnExistingFile()) + Expect(freshTmp).To(BeAnExistingFile()) + }) }) diff --git a/core/artwork/prune.go b/core/artwork/prune.go index ebbb0bbc4..b787dd1c9 100644 --- a/core/artwork/prune.go +++ b/core/artwork/prune.go @@ -13,7 +13,10 @@ const pruneMinAge = time.Hour func Prune(ctx context.Context, ds model.DataStore, store *ImageStore) error { repo := ds.Artwork(ctx) - orphans, err := repo.GetOrphanHashes(time.Now().Add(-pruneMinAge)) + // One grace cutoff for both the DB orphan check and the file sweep: files younger + // than the window may belong to acquisitions whose rows aren't committed yet. + cutoff := time.Now().Add(-pruneMinAge) + orphans, err := repo.GetOrphanHashes(cutoff) if err != nil { return err } @@ -41,7 +44,7 @@ func Prune(ctx context.Context, ds model.DataStore, store *ImageStore) error { for _, h := range hashes { known[h] = struct{}{} } - removed, err := store.Sweep(func(hash string) bool { + removed, err := store.Sweep(cutoff, func(hash string) bool { _, ok := known[hash] return ok }) diff --git a/core/artwork/prune_test.go b/core/artwork/prune_test.go index 2c8751674..0dd0b660f 100644 --- a/core/artwork/prune_test.go +++ b/core/artwork/prune_test.go @@ -60,6 +60,8 @@ var _ = Describe("Prune", func() { stray := []byte("no-row-bytes") h, _ := HashImage(bytes.NewReader(stray)) Expect(store.Write(h, "image/jpeg", bytes.NewReader(stray))).To(Succeed()) + old := time.Now().Add(-2 * time.Hour) + Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed()) Expect(Prune(context.Background(), ds, store)).To(Succeed()) diff --git a/persistence/artwork_repository.go b/persistence/artwork_repository.go index c87836ca9..145e0b3f7 100644 --- a/persistence/artwork_repository.go +++ b/persistence/artwork_repository.go @@ -45,16 +45,16 @@ func (r *artworkRepository) GetImage(hash string) (*model.Artwork, error) { } func (r *artworkRepository) PutImage(a *model.Artwork) error { - if a.CreatedAt.IsZero() { - a.CreatedAt = time.Now() - } + // created_at is the last-acquisition-write time the prune grace window keys on. + a.CreatedAt = time.Now() values, err := toSQLArgs(*a) if err != nil { return err } + // created_at=excluded.created_at: reacquiring an orphan must reset the prune grace window. ins := Insert(r.tableName).SetMap(values).Suffix(`ON CONFLICT (hash) DO UPDATE SET mime=excluded.mime, width=excluded.width, height=excluded.height, size_bytes=excluded.size_bytes, blur_hash=excluded.blur_hash, - source_path=excluded.source_path, ref_mtime=excluded.ref_mtime`) + source_path=excluded.source_path, ref_mtime=excluded.ref_mtime, created_at=excluded.created_at`) _, err = r.executeSQL(ins) return err } diff --git a/persistence/artwork_repository_test.go b/persistence/artwork_repository_test.go index 0fdb122b6..12456bcd1 100644 --- a/persistence/artwork_repository_test.go +++ b/persistence/artwork_repository_test.go @@ -8,6 +8,7 @@ import ( "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/pocketbase/dbx" ) // clearArtworkTables resets the shared test DB's artwork tables so specs don't leak state. @@ -48,6 +49,19 @@ var _ = Describe("ArtworkRepository", func() { Expect(got.BlurHash).To(Equal("XYZ")) }) + It("refreshes created_at when reacquiring an existing hash", func() { + Expect(repo.PutImage(&model.Artwork{Hash: "reacq", Mime: "image/jpeg"})).To(Succeed()) + _, err := GetDBXBuilder().NewQuery("UPDATE artwork SET created_at={:t} WHERE hash='reacq'"). + Bind(dbx.Params{"t": "2000-01-01 00:00:00"}).Execute() + Expect(err).ToNot(HaveOccurred()) + + Expect(repo.PutImage(&model.Artwork{Hash: "reacq", Mime: "image/png"})).To(Succeed()) + + got, err := repo.GetImage("reacq") + Expect(err).ToNot(HaveOccurred()) + Expect(got.CreatedAt).To(BeTemporally(">", time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC))) + }) + It("returns ErrNotFound for a missing hash", func() { _, err := repo.GetImage("nope") Expect(err).To(MatchError(model.ErrNotFound)) diff --git a/tests/mock_artwork_queue_repo.go b/tests/mock_artwork_queue_repo.go index dcec7486d..fceded1fb 100644 --- a/tests/mock_artwork_queue_repo.go +++ b/tests/mock_artwork_queue_repo.go @@ -11,6 +11,8 @@ type MockArtworkQueueRepo struct { model.ArtworkQueueRepository Data map[string]model.ArtworkQueueItem // keyed by iaKey(kind, id, imageType) Err error + // ItemArtworkSource, when set, backs EnqueueStaleAbsent with real item_artwork state. + ItemArtworkSource *MockArtworkRepo } func CreateMockArtworkQueueRepo() *MockArtworkQueueRepo { @@ -98,5 +100,28 @@ func (m *MockArtworkQueueRepo) Count() (int64, error) { } func (m *MockArtworkQueueRepo) EnqueueStaleAbsent(kind string, attemptedBefore time.Time) (int64, error) { - return 0, m.Err + if m.Err != nil || m.ItemArtworkSource == nil { + return 0, m.Err + } + now := time.Now() + var inserted int64 + for _, ia := range m.ItemArtworkSource.ItemData { + if ia.ItemKind != kind || ia.Hash != "" || !ia.AttemptedAt.Before(attemptedBefore) { + continue + } + k := iaKey(ia.ItemKind, ia.ItemID, ia.ImageType) + if _, ok := m.Data[k]; ok { // DO NOTHING: never touch existing queue rows + continue + } + m.Data[k] = model.ArtworkQueueItem{ + ItemKind: ia.ItemKind, + ItemID: ia.ItemID, + ImageType: ia.ImageType, + Priority: model.ArtworkPriorityRecheck, + RetryAt: now, + EnqueuedAt: now, + } + inserted++ + } + return inserted, nil } diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go index a6ca29df5..d8585fabf 100644 --- a/tests/mock_data_store.go +++ b/tests/mock_data_store.go @@ -267,7 +267,11 @@ func (db *MockDataStore) ArtworkQueue(ctx context.Context) model.ArtworkQueueRep if db.RealDS != nil { return db.RealDS.ArtworkQueue(ctx) } - db.MockedArtworkQueue = CreateMockArtworkQueueRepo() + q := CreateMockArtworkQueueRepo() + if aw, ok := db.Artwork(ctx).(*MockArtworkRepo); ok { + q.ItemArtworkSource = aw + } + db.MockedArtworkQueue = q return db.MockedArtworkQueue } From 623b7d6a6c864f6c340db8fa7724001b0f0ddb36 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 00:11:15 -0400 Subject: [PATCH 14/87] fix(artwork): atomic orphan deletion and timestamp semantics from review DeleteOrphans re-checks age+references at delete time, PutItemArtwork defaults attempted_at, queue mock timestamps mirror SQL. --- core/artwork/prune.go | 29 ++++++++++++++++++-------- core/artwork/prune_test.go | 20 ++++++++++++++++++ model/artwork.go | 3 ++- persistence/artwork_repository.go | 13 ++++++++++-- persistence/artwork_repository_test.go | 27 ++++++++++++++++++++++-- tests/mock_artwork_queue_repo.go | 12 +++++------ tests/mock_artwork_repo.go | 18 +++++++++++++++- 7 files changed, 100 insertions(+), 22 deletions(-) diff --git a/core/artwork/prune.go b/core/artwork/prune.go index b787dd1c9..de8811e14 100644 --- a/core/artwork/prune.go +++ b/core/artwork/prune.go @@ -16,24 +16,35 @@ func Prune(ctx context.Context, ds model.DataStore, store *ImageStore) error { // One grace cutoff for both the DB orphan check and the file sweep: files younger // than the window may belong to acquisitions whose rows aren't committed yet. cutoff := time.Now().Add(-pruneMinAge) - orphans, err := repo.GetOrphanHashes(cutoff) + candidates, err := repo.GetOrphanHashes(cutoff) if err != nil { return err } - if len(orphans) > 0 { - arts, err := repo.GetImages(orphans) + if len(candidates) > 0 { + arts, err := repo.GetImages(candidates) if err != nil { return err } - if err := repo.DeleteImages(orphans...); err != nil { + if err := repo.DeleteOrphans(cutoff, candidates); err != nil { return err } - for _, a := range arts { - if err := store.Remove(a.Hash, a.Mime); err != nil { - log.Warn(ctx, "Prune: could not remove artwork file", "hash", a.Hash, err) - } + // DeleteOrphans may spare candidates reacquired since the snapshot; only remove files + // for rows actually gone (absent from the post-delete re-read). + survivors, err := repo.GetImages(candidates) + if err != nil { + return err } - log.Info(ctx, "Prune: removed orphan artwork", "count", len(orphans)) + removed := 0 + for _, h := range candidates { + if _, ok := survivors[h]; ok { + continue + } + if err := store.Remove(h, arts[h].Mime); err != nil { + log.Warn(ctx, "Prune: could not remove artwork file", "hash", h, err) + } + removed++ + } + log.Info(ctx, "Prune: removed orphan artwork", "count", removed) } hashes, err := repo.GetAllHashes() diff --git a/core/artwork/prune_test.go b/core/artwork/prune_test.go index 0dd0b660f..aee8cd17c 100644 --- a/core/artwork/prune_test.go +++ b/core/artwork/prune_test.go @@ -56,6 +56,26 @@ var _ = Describe("Prune", func() { rc.Close() }) + It("spares a candidate reacquired between snapshot and delete", func() { + data := []byte("reacquired-bytes") + h, _ := HashImage(bytes.NewReader(data)) + Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) + Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg", + CreatedAt: time.Now().Add(-2 * time.Hour)})).To(Succeed()) + awRepo.OrphanHashes = []string{h} + // Reacquisition: an item now references the hash the snapshot flagged as orphan. + Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "a1", + ImageType: model.ImageTypePrimary, Hash: h, Source: "folder"})).To(Succeed()) + + Expect(Prune(context.Background(), ds, store)).To(Succeed()) + + _, err := awRepo.GetImage(h) + Expect(err).ToNot(HaveOccurred()) + rc, err := store.Open(h, "image/jpeg") + Expect(err).ToNot(HaveOccurred()) + rc.Close() + }) + It("sweeps store files that have no artwork row", func() { stray := []byte("no-row-bytes") h, _ := HashImage(bytes.NewReader(stray)) diff --git a/model/artwork.go b/model/artwork.go index 986a5c65d..5b50fbda0 100644 --- a/model/artwork.go +++ b/model/artwork.go @@ -65,7 +65,8 @@ type ArtworkRepository interface { GetImages(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) - DeleteImages(hashes ...string) error + // DeleteOrphans deletes the given hashes only if still unreferenced and older than cutoff (atomic re-check). + DeleteOrphans(createdBefore time.Time, hashes []string) error // Per-item state (item_artwork table) GetItemArtwork(kind, id, imageType string) (*ItemArtwork, error) PutItemArtwork(ia *ItemArtwork) error diff --git a/persistence/artwork_repository.go b/persistence/artwork_repository.go index 145e0b3f7..a18b9d4af 100644 --- a/persistence/artwork_repository.go +++ b/persistence/artwork_repository.go @@ -92,9 +92,14 @@ func (r *artworkRepository) GetOrphanHashes(createdBefore time.Time) ([]string, return hashes, err } -func (r *artworkRepository) DeleteImages(hashes ...string) error { +func (r *artworkRepository) DeleteOrphans(createdBefore time.Time, hashes []string) error { for chunk := range slices.Chunk(hashes, artworkBatchSize) { - if err := r.delete(Eq{"hash": chunk}); err != nil { + del := Delete(r.tableName).Where(And{ + Eq{"hash": chunk}, + Lt{"created_at": createdBefore}, + Expr("hash NOT IN (SELECT hash FROM " + itemArtworkTable + " WHERE hash <> '')"), + }) + if _, err := r.executeSQL(del); err != nil { return err } } @@ -116,6 +121,10 @@ func (r *artworkRepository) PutItemArtwork(ia *model.ItemArtwork) error { ia.ImageType = model.ImageTypePrimary } ia.UpdatedAt = time.Now() + // PutItemArtwork records the outcome of an attempt, so an unset attempted_at is now. + if ia.AttemptedAt.IsZero() { + ia.AttemptedAt = ia.UpdatedAt + } values, err := toSQLArgs(*ia) if err != nil { return err diff --git a/persistence/artwork_repository_test.go b/persistence/artwork_repository_test.go index 12456bcd1..b3971f88f 100644 --- a/persistence/artwork_repository_test.go +++ b/persistence/artwork_repository_test.go @@ -99,11 +99,25 @@ var _ = Describe("ArtworkRepository", func() { Expect(orphans).To(BeEmpty()) }) - It("deletes by hashes", func() { + It("deletes only unreferenced hashes older than the cutoff", func() { Expect(repo.PutImage(&model.Artwork{Hash: "d1", Mime: "image/jpeg"})).To(Succeed()) - Expect(repo.DeleteImages("d1")).To(Succeed()) + Expect(repo.PutImage(&model.Artwork{Hash: "dref", Mime: "image/jpeg"})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "a1", + ImageType: model.ImageTypePrimary, Hash: "dref", Source: "folder"})).To(Succeed()) + + Expect(repo.DeleteOrphans(time.Now().Add(time.Minute), []string{"d1", "dref"})).To(Succeed()) + _, err := repo.GetImage("d1") Expect(err).To(MatchError(model.ErrNotFound)) + _, err = repo.GetImage("dref") + Expect(err).ToNot(HaveOccurred()) + }) + + It("spares an unreferenced hash younger than the cutoff", func() { + Expect(repo.PutImage(&model.Artwork{Hash: "young", Mime: "image/jpeg"})).To(Succeed()) + Expect(repo.DeleteOrphans(time.Now().Add(-time.Hour), []string{"young"})).To(Succeed()) + _, err := repo.GetImage("young") + Expect(err).ToNot(HaveOccurred()) }) It("fetches a batch larger than the SQL variable limit", func() { @@ -135,6 +149,15 @@ var _ = Describe("ArtworkRepository", func() { Expect(got.UpdatedAt).ToNot(BeZero()) }) + It("defaults attempted_at to now when unset", func() { + before := time.Now().Add(-time.Second) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "noattempt", + ImageType: model.ImageTypePrimary, Hash: ""})).To(Succeed()) + got, err := repo.GetItemArtwork("ar", "noattempt", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(got.AttemptedAt).To(BeTemporally(">", before)) + }) + 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()) diff --git a/tests/mock_artwork_queue_repo.go b/tests/mock_artwork_queue_repo.go index fceded1fb..f62afe43a 100644 --- a/tests/mock_artwork_queue_repo.go +++ b/tests/mock_artwork_queue_repo.go @@ -28,19 +28,17 @@ func (m *MockArtworkQueueRepo) Enqueue(items ...model.ArtworkQueueItem) error { if it.ImageType == "" { it.ImageType = model.ImageTypePrimary } - if it.RetryAt.IsZero() { - it.RetryAt = now - } k := iaKey(it.ItemKind, it.ItemID, it.ImageType) + // Mirror the SQL: retry_at/enqueued_at are server-set, never taken from the caller. if prev, ok := m.Data[k]; ok { prev.Priority = max(prev.Priority, it.Priority) - prev.RetryAt = it.RetryAt + prev.RetryAt = now m.Data[k] = prev continue } - if it.EnqueuedAt.IsZero() { - it.EnqueuedAt = now - } + it.Attempts = 0 + it.RetryAt = now + it.EnqueuedAt = now m.Data[k] = it } return nil diff --git a/tests/mock_artwork_repo.go b/tests/mock_artwork_repo.go index 8ac4820e6..6575416b9 100644 --- a/tests/mock_artwork_repo.go +++ b/tests/mock_artwork_repo.go @@ -69,16 +69,29 @@ func (m *MockArtworkRepo) GetAllHashes() ([]string, error) { return hashes, nil } -func (m *MockArtworkRepo) DeleteImages(hashes ...string) error { +func (m *MockArtworkRepo) DeleteOrphans(createdBefore time.Time, hashes []string) error { if m.Err != nil { return m.Err } + // Mirror the SQL reference re-check; createdBefore is ignored in the mock for simplicity. for _, h := range hashes { + if m.referenced(h) { + continue + } delete(m.Data, h) } return nil } +func (m *MockArtworkRepo) referenced(hash string) bool { + for _, ia := range m.ItemData { + if ia.Hash == hash { + return true + } + } + return false +} + func (m *MockArtworkRepo) GetItemArtwork(kind, id, imageType string) (*model.ItemArtwork, error) { if m.Err != nil { return nil, m.Err @@ -97,6 +110,9 @@ func (m *MockArtworkRepo) PutItemArtwork(ia *model.ItemArtwork) error { ia.ImageType = model.ImageTypePrimary } ia.UpdatedAt = time.Now() + if ia.AttemptedAt.IsZero() { + ia.AttemptedAt = ia.UpdatedAt + } m.ItemData[iaKey(ia.ItemKind, ia.ItemID, ia.ImageType)] = *ia return nil } From bf614e66adad3f1ce99a479fc1a8a1cd02e48d71 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 00:19:45 -0400 Subject: [PATCH 15/87] fix(artwork): guard orphan file removal with the prune grace window Duplicate ImageStore writes refresh the file mtime and Remove skips files newer than the cutoff, so overlapping acquisitions cannot lose their store files to a concurrent prune. --- core/artwork/image_store.go | 20 +++++++++++++++-- core/artwork/image_store_test.go | 38 +++++++++++++++++++++++++++++++- core/artwork/prune.go | 4 +++- core/artwork/prune_test.go | 19 ++++++++++++++++ 4 files changed, 77 insertions(+), 4 deletions(-) diff --git a/core/artwork/image_store.go b/core/artwork/image_store.go index 517dfc879..ef27ed00a 100644 --- a/core/artwork/image_store.go +++ b/core/artwork/image_store.go @@ -54,6 +54,9 @@ func (s *ImageStore) path(hash, mimeType string) string { func (s *ImageStore) Write(hash, mimeType string, r io.Reader) error { dst := s.path(hash, mimeType) if _, err := os.Stat(dst); err == nil { + // A touched mtime marks the file live so a concurrent prune spares it. + now := time.Now() + _ = os.Chtimes(dst, now, now) return nil } if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { @@ -78,8 +81,21 @@ func (s *ImageStore) Open(hash, mimeType string) (io.ReadCloser, error) { return os.Open(s.path(hash, mimeType)) } -func (s *ImageStore) Remove(hash, mimeType string) error { - err := os.Remove(s.path(hash, mimeType)) +// Remove deletes the store file unless it is newer than olderThan, in which case +// an overlapping acquisition may have just touched it and be about to commit its row. +func (s *ImageStore) Remove(hash, mimeType string, olderThan time.Time) error { + path := s.path(hash, mimeType) + info, err := os.Stat(path) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + return err + } + if info.ModTime().After(olderThan) { + return nil + } + err = os.Remove(path) if errors.Is(err, fs.ErrNotExist) { return nil } diff --git a/core/artwork/image_store_test.go b/core/artwork/image_store_test.go index 2647f3b7f..89646deba 100644 --- a/core/artwork/image_store_test.go +++ b/core/artwork/image_store_test.go @@ -51,13 +51,49 @@ var _ = Describe("ImageStore", func() { Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed()) }) + It("refreshes the mtime on a duplicate write", func() { + data := []byte("touch-me") + h, _ := HashImage(bytes.NewReader(data)) + Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed()) + old := time.Now().Add(-2 * time.Hour) + Expect(os.Chtimes(store.path(h, "image/png"), old, old)).To(Succeed()) + + Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed()) + + info, err := os.Stat(store.path(h, "image/png")) + Expect(err).ToNot(HaveOccurred()) + Expect(info.ModTime()).To(BeTemporally(">", time.Now().Add(-time.Minute))) + }) + It("returns fs.ErrNotExist for missing images", func() { _, err := store.Open("beefbeefbeefbeef", "image/jpeg") Expect(os.IsNotExist(err)).To(BeTrue()) }) It("removes without error when already gone", func() { - Expect(store.Remove("beefbeefbeefbeef", "image/jpeg")).To(Succeed()) + Expect(store.Remove("beefbeefbeefbeef", "image/jpeg", time.Now())).To(Succeed()) + }) + + It("spares a file newer than the cutoff, removes an aged one", func() { + fresh := []byte("fresh") + hf, _ := HashImage(bytes.NewReader(fresh)) + Expect(store.Write(hf, "image/jpeg", bytes.NewReader(fresh))).To(Succeed()) + + aged := []byte("aged") + ha, _ := HashImage(bytes.NewReader(aged)) + Expect(store.Write(ha, "image/jpeg", bytes.NewReader(aged))).To(Succeed()) + old := time.Now().Add(-2 * time.Hour) + Expect(os.Chtimes(store.path(ha, "image/jpeg"), old, old)).To(Succeed()) + + cutoff := time.Now().Add(-time.Hour) + Expect(store.Remove(hf, "image/jpeg", cutoff)).To(Succeed()) + Expect(store.Remove(ha, "image/jpeg", cutoff)).To(Succeed()) + + rc, err := store.Open(hf, "image/jpeg") + Expect(err).ToNot(HaveOccurred()) + rc.Close() + _, err = store.Open(ha, "image/jpeg") + Expect(os.IsNotExist(err)).To(BeTrue()) }) It("sweeps unknown files, keeps known ones", func() { diff --git a/core/artwork/prune.go b/core/artwork/prune.go index de8811e14..70aac091e 100644 --- a/core/artwork/prune.go +++ b/core/artwork/prune.go @@ -39,7 +39,9 @@ func Prune(ctx context.Context, ds model.DataStore, store *ImageStore) error { if _, ok := survivors[h]; ok { continue } - if err := store.Remove(h, arts[h].Mime); err != nil { + // A spared fresh file is at worst a stray a later sweep reclaims; full + // worker/prune mutual exclusion is Phase 2's concern. + if err := store.Remove(h, arts[h].Mime, cutoff); err != nil { log.Warn(ctx, "Prune: could not remove artwork file", "hash", h, err) } removed++ diff --git a/core/artwork/prune_test.go b/core/artwork/prune_test.go index aee8cd17c..9d4aad350 100644 --- a/core/artwork/prune_test.go +++ b/core/artwork/prune_test.go @@ -36,6 +36,8 @@ var _ = Describe("Prune", func() { data := []byte("orphan-bytes") h, _ := HashImage(bytes.NewReader(data)) Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) + old := time.Now().Add(-2 * time.Hour) + Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed()) Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg", CreatedAt: time.Now().Add(-2 * time.Hour)})).To(Succeed()) awRepo.OrphanHashes = []string{h} @@ -76,6 +78,23 @@ var _ = Describe("Prune", func() { rc.Close() }) + It("spares an orphan file freshly touched by an overlapping acquisition", func() { + data := []byte("racing-bytes") + h, _ := HashImage(bytes.NewReader(data)) + Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) + Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg", + CreatedAt: time.Now().Add(-2 * time.Hour)})).To(Succeed()) + awRepo.OrphanHashes = []string{h} + // The row is legitimately orphaned, but a concurrent acquisition just touched the + // file's mtime (duplicate Write) and is about to commit a row referencing it. + + Expect(Prune(context.Background(), ds, store)).To(Succeed()) + + rc, err := store.Open(h, "image/jpeg") + Expect(err).ToNot(HaveOccurred()) + rc.Close() + }) + It("sweeps store files that have no artwork row", func() { stray := []byte("no-row-bytes") h, _ := HashImage(bytes.NewReader(stray)) From 8147f7c40bf2bafd58dc43c701dce5ad5e3908c3 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 00:28:51 -0400 Subject: [PATCH 16/87] fix(artwork): rewrite vanished duplicates and sweep stale mime variants Write falls through to a real write when the liveness touch fails, and sweep retention now matches the recorded mime's extension so obsolete variants are reclaimed. --- core/artwork/image_store.go | 11 ++++--- core/artwork/image_store_test.go | 44 ++++++++++++++++++++++++-- core/artwork/prune.go | 13 +++----- core/artwork/prune_test.go | 22 ++++++++++++- model/artwork.go | 4 +-- persistence/artwork_repository.go | 19 ++++++++--- persistence/artwork_repository_test.go | 7 ++-- tests/mock_artwork_repo.go | 10 +++--- 8 files changed, 99 insertions(+), 31 deletions(-) diff --git a/core/artwork/image_store.go b/core/artwork/image_store.go index ef27ed00a..178c19d10 100644 --- a/core/artwork/image_store.go +++ b/core/artwork/image_store.go @@ -56,8 +56,10 @@ func (s *ImageStore) Write(hash, mimeType string, r io.Reader) error { if _, err := os.Stat(dst); err == nil { // A touched mtime marks the file live so a concurrent prune spares it. now := time.Now() - _ = os.Chtimes(dst, now, now) - return nil + if err := os.Chtimes(dst, now, now); err == nil { + return nil + } + // touch failed (file likely pruned concurrently) — fall through and write it } if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { return err @@ -104,7 +106,7 @@ func (s *ImageStore) Remove(hash, mimeType string, olderThan time.Time) error { // Sweep removes store files not accepted by keep. Files modified after cutoff // (including temp files) are always kept: their acquisition row may not be committed yet. -func (s *ImageStore) Sweep(cutoff time.Time, keep func(hash string) bool) (int, error) { +func (s *ImageStore) Sweep(cutoff time.Time, keep func(hash, ext string) bool) (int, error) { removed := 0 err := filepath.WalkDir(s.root, func(path string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { @@ -120,7 +122,8 @@ func (s *ImageStore) Sweep(cutoff time.Time, keep func(hash string) bool) (int, name := d.Name() remove := strings.HasPrefix(name, ".") // abandoned temp file past the grace window if !remove { - remove = !keep(strings.TrimSuffix(name, filepath.Ext(name))) + ext := filepath.Ext(name) + remove = !keep(strings.TrimSuffix(name, ext), ext) } if remove { // #nosec G122 -- path comes from WalkDir over our own store root, no attacker-controlled symlinks diff --git a/core/artwork/image_store_test.go b/core/artwork/image_store_test.go index 89646deba..0bf721623 100644 --- a/core/artwork/image_store_test.go +++ b/core/artwork/image_store_test.go @@ -65,6 +65,22 @@ var _ = Describe("ImageStore", func() { Expect(info.ModTime()).To(BeTemporally(">", time.Now().Add(-time.Minute))) }) + It("rewrites the bytes when the existing file vanished before the liveness touch", func() { + data := []byte("vanishing") + h, _ := HashImage(bytes.NewReader(data)) + for range 10 { + Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed()) + Expect(os.Remove(store.path(h, "image/png"))).To(Succeed()) + Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed()) + + rc, err := store.Open(h, "image/png") + Expect(err).ToNot(HaveOccurred()) + got, _ := io.ReadAll(rc) + rc.Close() + Expect(got).To(Equal(data)) + } + }) + It("returns fs.ErrNotExist for missing images", func() { _, err := store.Open("beefbeefbeefbeef", "image/jpeg") Expect(os.IsNotExist(err)).To(BeTrue()) @@ -107,7 +123,7 @@ var _ = Describe("ImageStore", func() { old := time.Now().Add(-2 * time.Hour) Expect(os.Chtimes(store.path(h2, "image/jpeg"), old, old)).To(Succeed()) - removed, err := store.Sweep(time.Now().Add(-time.Hour), func(h string) bool { return h == h1 }) + removed, err := store.Sweep(time.Now().Add(-time.Hour), func(h, _ string) bool { return h == h1 }) Expect(err).ToNot(HaveOccurred()) Expect(removed).To(Equal(1)) _, err = store.Open(h2, "image/jpeg") @@ -117,12 +133,34 @@ var _ = Describe("ImageStore", func() { rc.Close() }) + It("sweeps a stale mime variant of a known hash, keeps the current one", func() { + data := []byte("same-bytes") + h, _ := HashImage(bytes.NewReader(data)) + Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed()) + Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) + old := time.Now().Add(-2 * time.Hour) + Expect(os.Chtimes(store.path(h, "image/png"), old, old)).To(Succeed()) + Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed()) + + // The recorded mime is image/jpeg, so the .png variant is obsolete. + removed, err := store.Sweep(time.Now().Add(-time.Hour), func(hash, ext string) bool { + return hash == h && ext == ".jpg" + }) + Expect(err).ToNot(HaveOccurred()) + Expect(removed).To(Equal(1)) + _, err = store.Open(h, "image/png") + Expect(os.IsNotExist(err)).To(BeTrue()) + rc, err := store.Open(h, "image/jpeg") + Expect(err).ToNot(HaveOccurred()) + rc.Close() + }) + It("keeps young unknown files inside the grace window", func() { d := []byte("fresh-orphan") h, _ := HashImage(bytes.NewReader(d)) Expect(store.Write(h, "image/jpeg", bytes.NewReader(d))).To(Succeed()) - removed, err := store.Sweep(time.Now().Add(-time.Hour), func(string) bool { return false }) + removed, err := store.Sweep(time.Now().Add(-time.Hour), func(string, string) bool { return false }) Expect(err).ToNot(HaveOccurred()) Expect(removed).To(Equal(0)) rc, err := store.Open(h, "image/jpeg") @@ -139,7 +177,7 @@ var _ = Describe("ImageStore", func() { freshTmp := filepath.Join(root, ".fresh.tmp") Expect(os.WriteFile(freshTmp, []byte("y"), 0600)).To(Succeed()) - removed, err := store.Sweep(time.Now().Add(-time.Hour), func(string) bool { return true }) + removed, err := store.Sweep(time.Now().Add(-time.Hour), func(string, string) bool { return true }) Expect(err).ToNot(HaveOccurred()) Expect(removed).To(Equal(1)) Expect(oldTmp).ToNot(BeAnExistingFile()) diff --git a/core/artwork/prune.go b/core/artwork/prune.go index 70aac091e..db1e4fdc1 100644 --- a/core/artwork/prune.go +++ b/core/artwork/prune.go @@ -49,17 +49,14 @@ func Prune(ctx context.Context, ds model.DataStore, store *ImageStore) error { log.Info(ctx, "Prune: removed orphan artwork", "count", removed) } - hashes, err := repo.GetAllHashes() + mimes, err := repo.GetAllMimes() if err != nil { return err } - known := make(map[string]struct{}, len(hashes)) - for _, h := range hashes { - known[h] = struct{}{} - } - removed, err := store.Sweep(cutoff, func(hash string) bool { - _, ok := known[hash] - return ok + removed, err := store.Sweep(cutoff, func(hash, ext string) bool { + // A known hash under a stale extension is a superseded mime variant — reclaim it. + m, ok := mimes[hash] + return ok && ext == extForMime(m) }) if err != nil { return err diff --git a/core/artwork/prune_test.go b/core/artwork/prune_test.go index 9d4aad350..2e5c80d23 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) GetAllHashes() ([]string, error) { +func (f *flakyGetArtworkRepo) GetAllMimes() (map[string]string, error) { return nil, errors.New("db locked") } @@ -108,6 +108,26 @@ var _ = Describe("Prune", func() { Expect(os.IsNotExist(err)).To(BeTrue()) }) + It("sweeps an obsolete mime variant of a reacquired hash", func() { + data := []byte("variant-bytes") + h, _ := HashImage(bytes.NewReader(data)) + Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed()) + Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) + old := time.Now().Add(-2 * time.Hour) + Expect(os.Chtimes(store.path(h, "image/png"), old, old)).To(Succeed()) + Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed()) + // The row records the current mime; the .png file is a superseded variant. + Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed()) + + Expect(Prune(context.Background(), ds, store)).To(Succeed()) + + _, err := store.Open(h, "image/png") + Expect(os.IsNotExist(err)).To(BeTrue()) + rc, err := store.Open(h, "image/jpeg") + Expect(err).ToNot(HaveOccurred()) + rc.Close() + }) + It("never sweeps files on a transient DB error", func() { ds.MockedArtwork = &flakyGetArtworkRepo{MockArtworkRepo: tests.CreateMockArtworkRepo()} diff --git a/model/artwork.go b/model/artwork.go index 5b50fbda0..f5f3ab4db 100644 --- a/model/artwork.go +++ b/model/artwork.go @@ -73,8 +73,8 @@ type ArtworkRepository interface { 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) - // GetAllHashes returns every stored artwork hash, for sweep membership checks. - GetAllHashes() ([]string, error) + // GetAllMimes returns hash -> current mime for every stored artwork, for sweep retention checks. + GetAllMimes() (map[string]string, error) } type ArtworkQueueRepository interface { diff --git a/persistence/artwork_repository.go b/persistence/artwork_repository.go index a18b9d4af..05912a0d6 100644 --- a/persistence/artwork_repository.go +++ b/persistence/artwork_repository.go @@ -74,11 +74,20 @@ func (r *artworkRepository) GetImages(hashes []string) (map[string]model.Artwork return res, nil } -func (r *artworkRepository) GetAllHashes() ([]string, error) { - sel := Select("hash").From(r.tableName) - var hashes []string - err := r.queryAllSlice(sel, &hashes) - return hashes, err +func (r *artworkRepository) GetAllMimes() (map[string]string, error) { + sel := Select("hash", "mime").From(r.tableName) + var rows []struct { + Hash string + Mime string + } + if err := r.queryAll(sel, &rows); err != nil { + return nil, err + } + res := make(map[string]string, len(rows)) + for _, row := range rows { + res[row.Hash] = row.Mime + } + return res, nil } func (r *artworkRepository) GetOrphanHashes(createdBefore time.Time) ([]string, error) { diff --git a/persistence/artwork_repository_test.go b/persistence/artwork_repository_test.go index b3971f88f..a13b1e7cb 100644 --- a/persistence/artwork_repository_test.go +++ b/persistence/artwork_repository_test.go @@ -76,12 +76,13 @@ var _ = Describe("ArtworkRepository", func() { Expect(got["b2"].Mime).To(Equal("image/png")) }) - It("returns every stored hash", func() { + It("returns every stored hash with its current mime", func() { Expect(repo.PutImage(&model.Artwork{Hash: "all1", Mime: "image/jpeg"})).To(Succeed()) Expect(repo.PutImage(&model.Artwork{Hash: "all2", Mime: "image/png"})).To(Succeed()) - hashes, err := repo.GetAllHashes() + mimes, err := repo.GetAllMimes() Expect(err).ToNot(HaveOccurred()) - Expect(hashes).To(ConsistOf("all1", "all2")) + Expect(mimes).To(HaveKeyWithValue("all1", "image/jpeg")) + Expect(mimes).To(HaveKeyWithValue("all2", "image/png")) }) It("finds orphans older than cutoff, honoring item_artwork references", func() { diff --git a/tests/mock_artwork_repo.go b/tests/mock_artwork_repo.go index 6575416b9..851ee7f48 100644 --- a/tests/mock_artwork_repo.go +++ b/tests/mock_artwork_repo.go @@ -58,15 +58,15 @@ func (m *MockArtworkRepo) GetOrphanHashes(createdBefore time.Time) ([]string, er return m.OrphanHashes, m.Err } -func (m *MockArtworkRepo) GetAllHashes() ([]string, error) { +func (m *MockArtworkRepo) GetAllMimes() (map[string]string, error) { if m.Err != nil { return nil, m.Err } - hashes := make([]string, 0, len(m.Data)) - for h := range m.Data { - hashes = append(hashes, h) + mimes := make(map[string]string, len(m.Data)) + for h, a := range m.Data { + mimes[h] = a.Mime } - return hashes, nil + return mimes, nil } func (m *MockArtworkRepo) DeleteOrphans(createdBefore time.Time, hashes []string) error { From 034cd17498cbee76b0044edfc9fd850407031630 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 00:36:22 -0400 Subject: [PATCH 17/87] fix(artwork): index artwork_queue in dequeue order The previous leading retry_at range column forced a temp B-tree sort of the whole eligible set on every DequeueBatch; ordering the index by (priority DESC, enqueued_at) lets scans stop after the batch size. --- db/migrations/20260722023032_add_artwork_tables.sql | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/db/migrations/20260722023032_add_artwork_tables.sql b/db/migrations/20260722023032_add_artwork_tables.sql index 94e0ebe5f..7133b71ce 100644 --- a/db/migrations/20260722023032_add_artwork_tables.sql +++ b/db/migrations/20260722023032_add_artwork_tables.sql @@ -33,7 +33,8 @@ CREATE TABLE artwork_queue ( enqueued_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (item_kind, item_id, image_type) ) WITHOUT ROWID; -CREATE INDEX ix_artwork_queue_drain ON artwork_queue(retry_at, priority, enqueued_at); +-- Ordered to match DequeueBatch (priority DESC, enqueued_at) so drains stop after n rows; retry_at makes it covering. +CREATE INDEX ix_artwork_queue_drain ON artwork_queue(priority DESC, enqueued_at, retry_at); -- +goose Down DROP TABLE artwork_queue; From 0147cc59b1a6dbefb0d688588b8d1c6d3f2bf066 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 00:42:42 -0400 Subject: [PATCH 18/87] fix(artwork): honor the orphan cutoff in the repository mock The mock's DeleteOrphans now applies createdBefore like the SQL implementation, and a new spec covers a freshly reacquired row surviving prune. --- core/artwork/prune_test.go | 18 ++++++++++++++++++ tests/mock_artwork_repo.go | 5 ++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/core/artwork/prune_test.go b/core/artwork/prune_test.go index 2e5c80d23..4a45ddd13 100644 --- a/core/artwork/prune_test.go +++ b/core/artwork/prune_test.go @@ -78,6 +78,24 @@ var _ = Describe("Prune", func() { rc.Close() }) + It("spares a candidate whose row was freshly recreated (created_at inside the grace window)", func() { + data := []byte("fresh-reacquired-bytes") + h, _ := HashImage(bytes.NewReader(data)) + Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) + // Reacquisition refreshed created_at after the snapshot; still unreferenced. + Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg", + CreatedAt: time.Now()})).To(Succeed()) + awRepo.OrphanHashes = []string{h} + + Expect(Prune(context.Background(), ds, store)).To(Succeed()) + + _, err := awRepo.GetImage(h) + Expect(err).ToNot(HaveOccurred()) + rc, err := store.Open(h, "image/jpeg") + Expect(err).ToNot(HaveOccurred()) + rc.Close() + }) + It("spares an orphan file freshly touched by an overlapping acquisition", func() { data := []byte("racing-bytes") h, _ := HashImage(bytes.NewReader(data)) diff --git a/tests/mock_artwork_repo.go b/tests/mock_artwork_repo.go index 851ee7f48..9565abd7b 100644 --- a/tests/mock_artwork_repo.go +++ b/tests/mock_artwork_repo.go @@ -73,11 +73,14 @@ func (m *MockArtworkRepo) DeleteOrphans(createdBefore time.Time, hashes []string if m.Err != nil { return m.Err } - // Mirror the SQL reference re-check; createdBefore is ignored in the mock for simplicity. + // Mirror the SQL re-check: only unreferenced rows older than the cutoff are deleted. for _, h := range hashes { if m.referenced(h) { continue } + if a, ok := m.Data[h]; ok && !a.CreatedAt.Before(createdBefore) { + continue + } delete(m.Data, h) } return nil From ebbe533c6a8c1876732d71385c2571673977ede5 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 00:51:13 -0400 Subject: [PATCH 19/87] fix(artwork): reject malformed hashes in ImageStore operations Known-absent states carry an empty hash and malformed persisted hashes could panic path sharding or inject separators; Write/Open/Remove now return an error for anything but 16 lowercase hex chars. --- core/artwork/image_store.go | 23 +++++++++++++++++++++++ core/artwork/image_store_test.go | 9 +++++++++ 2 files changed, 32 insertions(+) diff --git a/core/artwork/image_store.go b/core/artwork/image_store.go index 178c19d10..4b4e482c9 100644 --- a/core/artwork/image_store.go +++ b/core/artwork/image_store.go @@ -47,11 +47,28 @@ func extForMime(m string) string { return ".img" } +// validHash rejects anything but 16 lowercase hex chars: known-absent states carry "", +// and malformed persisted hashes must never reach path sharding (slice panics, separators). +func validHash(hash string) bool { + if len(hash) != 16 { + return false + } + for _, c := range []byte(hash) { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { + return false + } + } + return true +} + func (s *ImageStore) path(hash, mimeType string) string { return filepath.Join(s.root, hash[0:2], hash[2:4], hash+extForMime(mimeType)) } func (s *ImageStore) Write(hash, mimeType string, r io.Reader) error { + if !validHash(hash) { + return fmt.Errorf("imagestore: invalid hash %q", hash) + } dst := s.path(hash, mimeType) if _, err := os.Stat(dst); err == nil { // A touched mtime marks the file live so a concurrent prune spares it. @@ -80,12 +97,18 @@ func (s *ImageStore) Write(hash, mimeType string, r io.Reader) error { } func (s *ImageStore) Open(hash, mimeType string) (io.ReadCloser, error) { + if !validHash(hash) { + return nil, fmt.Errorf("imagestore: invalid hash %q", hash) + } return os.Open(s.path(hash, mimeType)) } // Remove deletes the store file unless it is newer than olderThan, in which case // an overlapping acquisition may have just touched it and be about to commit its row. func (s *ImageStore) Remove(hash, mimeType string, olderThan time.Time) error { + if !validHash(hash) { + return fmt.Errorf("imagestore: invalid hash %q", hash) + } path := s.path(hash, mimeType) info, err := os.Stat(path) if errors.Is(err, fs.ErrNotExist) { diff --git a/core/artwork/image_store_test.go b/core/artwork/image_store_test.go index 0bf721623..214cab3ad 100644 --- a/core/artwork/image_store_test.go +++ b/core/artwork/image_store_test.go @@ -90,6 +90,15 @@ var _ = Describe("ImageStore", func() { Expect(store.Remove("beefbeefbeefbeef", "image/jpeg", time.Now())).To(Succeed()) }) + It("rejects invalid hashes instead of panicking", func() { + for _, h := range []string{"", "ab", "BEEFBEEFBEEFBEEF", "../../../../etcpw", "beefbeefbeefbee/"} { + Expect(store.Write(h, "image/jpeg", bytes.NewReader([]byte("x")))).To(MatchError(ContainSubstring("invalid hash"))) + _, err := store.Open(h, "image/jpeg") + Expect(err).To(MatchError(ContainSubstring("invalid hash"))) + Expect(store.Remove(h, "image/jpeg", time.Now())).To(MatchError(ContainSubstring("invalid hash"))) + } + }) + It("spares a file newer than the cutoff, removes an aged one", func() { fresh := []byte("fresh") hf, _ := HashImage(bytes.NewReader(fresh)) From c04c8ee02a68e7c97dc99f8b7f06066894aecf63 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 00:58:22 -0400 Subject: [PATCH 20/87] fix(artwork): mock PutImage refreshes created_at like the SQL repository Prune specs now age fixtures directly instead of seeding stale timestamps through the upsert. --- core/artwork/prune_test.go | 22 ++++++++++++++-------- tests/mock_artwork_repo.go | 5 ++--- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/core/artwork/prune_test.go b/core/artwork/prune_test.go index 4a45ddd13..1f621363b 100644 --- a/core/artwork/prune_test.go +++ b/core/artwork/prune_test.go @@ -32,14 +32,21 @@ var _ = Describe("Prune", func() { store = NewImageStore(GinkgoT().TempDir()) }) + // PutImage refreshes created_at like the SQL repo, so fixtures are aged directly. + ageArtwork := func(h string, t time.Time) { + a := awRepo.Data[h] + a.CreatedAt = t + awRepo.Data[h] = a + } + It("deletes orphan rows and their store files, keeps referenced ones", func() { data := []byte("orphan-bytes") h, _ := HashImage(bytes.NewReader(data)) Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) old := time.Now().Add(-2 * time.Hour) Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed()) - Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg", - CreatedAt: time.Now().Add(-2 * time.Hour)})).To(Succeed()) + Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed()) + ageArtwork(h, old) awRepo.OrphanHashes = []string{h} kept := []byte("kept-bytes") @@ -62,8 +69,8 @@ var _ = Describe("Prune", func() { data := []byte("reacquired-bytes") h, _ := HashImage(bytes.NewReader(data)) Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) - Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg", - CreatedAt: time.Now().Add(-2 * time.Hour)})).To(Succeed()) + Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed()) + ageArtwork(h, time.Now().Add(-2*time.Hour)) awRepo.OrphanHashes = []string{h} // Reacquisition: an item now references the hash the snapshot flagged as orphan. Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "a1", @@ -83,8 +90,7 @@ var _ = Describe("Prune", func() { h, _ := HashImage(bytes.NewReader(data)) Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) // Reacquisition refreshed created_at after the snapshot; still unreferenced. - Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg", - CreatedAt: time.Now()})).To(Succeed()) + Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed()) awRepo.OrphanHashes = []string{h} Expect(Prune(context.Background(), ds, store)).To(Succeed()) @@ -100,8 +106,8 @@ var _ = Describe("Prune", func() { data := []byte("racing-bytes") h, _ := HashImage(bytes.NewReader(data)) Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) - Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg", - CreatedAt: time.Now().Add(-2 * time.Hour)})).To(Succeed()) + Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed()) + ageArtwork(h, time.Now().Add(-2*time.Hour)) awRepo.OrphanHashes = []string{h} // The row is legitimately orphaned, but a concurrent acquisition just touched the // file's mtime (duplicate Write) and is about to commit a row referencing it. diff --git a/tests/mock_artwork_repo.go b/tests/mock_artwork_repo.go index 9565abd7b..13ad4d5e1 100644 --- a/tests/mock_artwork_repo.go +++ b/tests/mock_artwork_repo.go @@ -34,9 +34,8 @@ func (m *MockArtworkRepo) PutImage(a *model.Artwork) error { if m.Err != nil { return m.Err } - if a.CreatedAt.IsZero() { - a.CreatedAt = time.Now() - } + // Mirrors the SQL repository: every upsert refreshes created_at. Age fixtures via Data directly. + a.CreatedAt = time.Now() m.Data[a.Hash] = *a return nil } From 1f818e7633ae917e29b67004b18f894e93ac918d Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 15:52:54 -0400 Subject: [PATCH 21/87] fix(artwork): store backing-file provenance per item, not per hash --- .../20260722023032_add_artwork_tables.sql | 4 ++-- model/artwork.go | 20 ++++++++++--------- persistence/artwork_repository.go | 5 ++--- persistence/artwork_repository_test.go | 8 ++++++-- 4 files changed, 21 insertions(+), 16 deletions(-) diff --git a/db/migrations/20260722023032_add_artwork_tables.sql b/db/migrations/20260722023032_add_artwork_tables.sql index 7133b71ce..bc400f90e 100644 --- a/db/migrations/20260722023032_add_artwork_tables.sql +++ b/db/migrations/20260722023032_add_artwork_tables.sql @@ -6,8 +6,6 @@ CREATE TABLE artwork ( height INTEGER NOT NULL DEFAULT 0, size_bytes INTEGER NOT NULL DEFAULT 0, blur_hash TEXT NOT NULL DEFAULT '', - source_path TEXT NOT NULL DEFAULT '', - ref_mtime INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); @@ -17,6 +15,8 @@ CREATE TABLE item_artwork ( image_type TEXT NOT NULL DEFAULT 'primary', hash TEXT NOT NULL DEFAULT '', source TEXT NOT NULL DEFAULT '', + source_path TEXT NOT NULL DEFAULT '', + ref_mtime INTEGER NOT NULL DEFAULT 0, attempted_at TIMESTAMP, updated_at TIMESTAMP, PRIMARY KEY (item_kind, item_id, image_type) diff --git a/model/artwork.go b/model/artwork.go index f5f3ab4db..7cb117764 100644 --- a/model/artwork.go +++ b/model/artwork.go @@ -4,15 +4,13 @@ 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"` + 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"` + CreatedAt time.Time `structs:"created_at"` } const ImageTypePrimary = "primary" @@ -24,6 +22,10 @@ type ItemArtwork struct { ImageType string `structs:"image_type"` Hash string `structs:"hash"` Source string `structs:"source"` + // SourcePath is the backing file (folder/upload: the image; embedded: the audio file); "" otherwise. + SourcePath string `structs:"source_path"` + // RefMtime is SourcePath's mtime at resolution; 0 when there is no SourcePath. + RefMtime int64 `structs:"ref_mtime"` // attempted_at/updated_at are nullable in the schema but always set by PutItemArtwork; // raw inserts must set them too, since these non-pointer time.Time fields fail to scan NULL. AttemptedAt time.Time `structs:"attempted_at"` diff --git a/persistence/artwork_repository.go b/persistence/artwork_repository.go index 05912a0d6..505e3839d 100644 --- a/persistence/artwork_repository.go +++ b/persistence/artwork_repository.go @@ -53,8 +53,7 @@ func (r *artworkRepository) PutImage(a *model.Artwork) error { } // created_at=excluded.created_at: reacquiring an orphan must reset the prune grace window. ins := Insert(r.tableName).SetMap(values).Suffix(`ON CONFLICT (hash) DO UPDATE SET mime=excluded.mime, width=excluded.width, - height=excluded.height, size_bytes=excluded.size_bytes, blur_hash=excluded.blur_hash, - source_path=excluded.source_path, ref_mtime=excluded.ref_mtime, created_at=excluded.created_at`) + height=excluded.height, size_bytes=excluded.size_bytes, blur_hash=excluded.blur_hash, created_at=excluded.created_at`) _, err = r.executeSQL(ins) return err } @@ -139,7 +138,7 @@ func (r *artworkRepository) PutItemArtwork(ia *model.ItemArtwork) error { return err } ins := Insert(itemArtworkTable).SetMap(values).Suffix(`ON CONFLICT (item_kind, item_id, image_type) DO UPDATE SET - hash=excluded.hash, source=excluded.source, + hash=excluded.hash, source=excluded.source, source_path=excluded.source_path, ref_mtime=excluded.ref_mtime, attempted_at=excluded.attempted_at, updated_at=excluded.updated_at`) _, err = r.items.executeSQL(ins) return err diff --git a/persistence/artwork_repository_test.go b/persistence/artwork_repository_test.go index a13b1e7cb..82c2888c8 100644 --- a/persistence/artwork_repository_test.go +++ b/persistence/artwork_repository_test.go @@ -137,16 +137,20 @@ var _ = Describe("ArtworkRepository", func() { }) Context("item state", func() { - It("upserts and reads state", func() { + It("upserts and reads state, including per-item provenance", func() { ia := &model.ItemArtwork{ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, - Hash: "h1", Source: "folder", AttemptedAt: time.Now()} + Hash: "h1", Source: "folder", SourcePath: "/music/a/cover.jpg", RefMtime: 111, AttemptedAt: time.Now()} Expect(repo.PutItemArtwork(ia)).To(Succeed()) ia.Source = "embedded" + ia.SourcePath = "/music/a/track.mp3" + ia.RefMtime = 222 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.SourcePath).To(Equal("/music/a/track.mp3")) + Expect(got.RefMtime).To(Equal(int64(222))) Expect(got.UpdatedAt).ToNot(BeZero()) }) From 2efa697e52433d106d1d2aa34e7c30de4168375a Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 08:22:49 -0400 Subject: [PATCH 22/87] feat(artwork): import blurhash encoder from #5797 --- core/artwork/blurhash/blurhash.go | 177 +++++++++++++++++++ core/artwork/blurhash/blurhash_bench_test.go | 41 +++++ core/artwork/blurhash/blurhash_suite_test.go | 17 ++ core/artwork/blurhash/blurhash_test.go | 113 ++++++++++++ 4 files changed, 348 insertions(+) create mode 100644 core/artwork/blurhash/blurhash.go create mode 100644 core/artwork/blurhash/blurhash_bench_test.go create mode 100644 core/artwork/blurhash/blurhash_suite_test.go create mode 100644 core/artwork/blurhash/blurhash_test.go diff --git a/core/artwork/blurhash/blurhash.go b/core/artwork/blurhash/blurhash.go new file mode 100644 index 000000000..302ec83c0 --- /dev/null +++ b/core/artwork/blurhash/blurhash.go @@ -0,0 +1,177 @@ +// Package blurhash implements the blurhash encoding algorithm (https://github.com/woltapp/blurhash), +// matching Jellyfin's parameters so clients tuned against Jellyfin see equivalent hashes. +package blurhash + +import ( + "errors" + "image" + "image/draw" + "math" + "strings" + "sync" + + xdraw "golang.org/x/image/draw" +) + +const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~" + +// maxInputSize matches Jellyfin: larger inputs are slower with no visually discernible difference. +const maxInputSize = 128 + +// Components picks x/y component counts for an image, targeting ~16 near-square tiles (Jellyfin's formula). +func Components(width, height int) (int, int) { + if width <= 0 || height <= 0 { + return 0, 0 + } + xf := math.Sqrt(16.0 * float64(width) / float64(height)) + yf := xf * float64(height) / float64(width) + return min(int(xf)+1, 9), min(int(yf)+1, 9) +} + +// Encode returns the blurhash of img using xComp x yComp components. +func Encode(img image.Image, xComp, yComp int) (string, error) { + if xComp < 1 || xComp > 9 || yComp < 1 || yComp > 9 { + return "", errors.New("blurhash: components must be between 1 and 9") + } + rgba := toRGBA(downscale(img)) + bounds := rgba.Bounds() + w, h := bounds.Dx(), bounds.Dy() + if w == 0 || h == 0 { + return "", errors.New("blurhash: empty image") + } + + cosX := make([][]float64, xComp) + for i := range cosX { + cosX[i] = make([]float64, w) + for x := range cosX[i] { + cosX[i][x] = math.Cos(math.Pi * float64(i) * float64(x) / float64(w)) + } + } + cosY := make([][]float64, yComp) + for j := range cosY { + cosY[j] = make([]float64, h) + for y := range cosY[j] { + cosY[j][y] = math.Cos(math.Pi * float64(j) * float64(y) / float64(h)) + } + } + + lin := srgbToLinearTable() + factors := make([][3]float64, xComp*yComp) + for y := 0; y < h; y++ { + row := rgba.Pix[y*rgba.Stride:] + for x := 0; x < w; x++ { + p := x * 4 + lr, lg, lb := lin[row[p]], lin[row[p+1]], lin[row[p+2]] + for j := 0; j < yComp; j++ { + for i := 0; i < xComp; i++ { + basis := cosX[i][x] * cosY[j][y] + f := &factors[j*xComp+i] + f[0] += basis * lr + f[1] += basis * lg + f[2] += basis * lb + } + } + } + } + for idx := range factors { + norm := 2.0 + if idx == 0 { + norm = 1.0 + } + scale := norm / float64(w*h) + factors[idx][0] *= scale + factors[idx][1] *= scale + factors[idx][2] *= scale + } + + var sb strings.Builder + sb.WriteString(Encode83((xComp-1)+(yComp-1)*9, 1)) + + ac := factors[1:] + maxVal := 1.0 + if len(ac) > 0 { + actualMax := 0.0 + for _, f := range ac { + actualMax = max(actualMax, math.Abs(f[0]), math.Abs(f[1]), math.Abs(f[2])) + } + quantMax := int(math.Max(0, math.Min(82, math.Floor(actualMax*166-0.5)))) + maxVal = float64(quantMax+1) / 166 + sb.WriteString(Encode83(quantMax, 1)) + } else { + sb.WriteString(Encode83(0, 1)) + } + + dc := factors[0] + sb.WriteString(Encode83(linearToSRGB(dc[0])<<16|linearToSRGB(dc[1])<<8|linearToSRGB(dc[2]), 4)) + for _, f := range ac { + sb.WriteString(Encode83(quantAC(f[0], maxVal)*19*19+quantAC(f[1], maxVal)*19+quantAC(f[2], maxVal), 2)) + } + return sb.String(), nil +} + +// toRGBA gives the pixel loop direct Pix access, avoiding a per-pixel allocation through the +// image.At interface (~16k allocs per encode). +func toRGBA(img image.Image) *image.RGBA { + if rgba, ok := img.(*image.RGBA); ok { + return rgba + } + b := img.Bounds() + dst := image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy())) + draw.Draw(dst, dst.Bounds(), img, b.Min, draw.Src) + return dst +} + +var srgbToLinearTable = sync.OnceValue(func() *[256]float64 { + var t [256]float64 + for i := range t { + t[i] = srgbToLinear(i) + } + return &t +}) + +func downscale(img image.Image) image.Image { + b := img.Bounds() + w, h := b.Dx(), b.Dy() + if w <= maxInputSize && h <= maxInputSize { + return img + } + scale := float64(maxInputSize) / float64(max(w, h)) + dst := image.NewRGBA(image.Rect(0, 0, max(1, int(float64(w)*scale)), max(1, int(float64(h)*scale)))) + xdraw.ApproxBiLinear.Scale(dst, dst.Bounds(), img, b, draw.Src, nil) + return dst +} + +func quantAC(v, maxVal float64) int { + return int(math.Max(0, math.Min(18, math.Floor(signPow(v/maxVal, 0.5)*9+9.5)))) +} + +func signPow(v, exp float64) float64 { + return math.Copysign(math.Pow(math.Abs(v), exp), v) +} + +func srgbToLinear(v int) float64 { + f := float64(v) / 255 + if f <= 0.04045 { + return f / 12.92 + } + return math.Pow((f+0.055)/1.055, 2.4) +} + +func linearToSRGB(v float64) int { + v = math.Min(math.Max(0, v), 1) + if v <= 0.0031308 { + return int(v*12.92*255 + 0.5) + } + return int((1.055*math.Pow(v, 1/2.4)-0.055)*255 + 0.5) +} + +// Encode83 encodes value as a fixed-width, big-endian base83 string of the given length, using the +// blurhash spec's alphabet. +func Encode83(value, length int) string { + b := make([]byte, length) + for i := length - 1; i >= 0; i-- { + b[i] = alphabet[value%83] + value /= 83 + } + return string(b) +} diff --git a/core/artwork/blurhash/blurhash_bench_test.go b/core/artwork/blurhash/blurhash_bench_test.go new file mode 100644 index 000000000..43b593eb5 --- /dev/null +++ b/core/artwork/blurhash/blurhash_bench_test.go @@ -0,0 +1,41 @@ +package blurhash_test + +import ( + "fmt" + "image" + "image/color" + "testing" + + "github.com/navidrome/navidrome/core/artwork/blurhash" +) + +// benchImage builds a deterministic gradient so runs are comparable across revisions. +func benchImage(size int) image.Image { + img := image.NewNRGBA(image.Rect(0, 0, size, size)) + for y := 0; y < size; y++ { + for x := 0; x < size; x++ { + img.SetNRGBA(x, y, color.NRGBA{ + R: uint8(255 * x / size), + G: uint8(255 * y / size), + B: uint8((x + y) * 255 / (2 * size)), + A: 255, + }) + } + } + return img +} + +func BenchmarkEncode(b *testing.B) { + for _, size := range []int{100, 300, 600, 900, 1200, 1500} { + img := benchImage(size) + x, y := blurhash.Components(size, size) + b.Run(fmt.Sprintf("%dx%d", size, size), func(b *testing.B) { + b.ReportAllocs() + for range b.N { + if _, err := blurhash.Encode(img, x, y); err != nil { + b.Fatal(err) + } + } + }) + } +} diff --git a/core/artwork/blurhash/blurhash_suite_test.go b/core/artwork/blurhash/blurhash_suite_test.go new file mode 100644 index 000000000..b0e96d7a7 --- /dev/null +++ b/core/artwork/blurhash/blurhash_suite_test.go @@ -0,0 +1,17 @@ +package blurhash_test + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestBlurHash(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "BlurHash Suite") +} diff --git a/core/artwork/blurhash/blurhash_test.go b/core/artwork/blurhash/blurhash_test.go new file mode 100644 index 000000000..18dd86b99 --- /dev/null +++ b/core/artwork/blurhash/blurhash_test.go @@ -0,0 +1,113 @@ +package blurhash_test + +import ( + "image" + "image/color" + "strings" + + "github.com/navidrome/navidrome/core/artwork/blurhash" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~" + +func decode83(s string) int { + v := 0 + for _, c := range s { + v = v*83 + strings.IndexRune(alphabet, c) + } + return v +} + +func solidImage(w, h int, c color.NRGBA) image.Image { + img := image.NewNRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.SetNRGBA(x, y, c) + } + } + return img +} + +func gradientImage(w, h int) image.Image { + img := image.NewNRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.SetNRGBA(x, y, color.NRGBA{R: uint8(255 * x / w), G: uint8(255 * y / h), B: 128, A: 255}) + } + } + return img +} + +var _ = Describe("Components", func() { + DescribeTable("derives component counts from aspect ratio (Jellyfin formula)", + func(w, h, expectedX, expectedY int) { + x, y := blurhash.Components(w, h) + Expect(x).To(Equal(expectedX)) + Expect(y).To(Equal(expectedY)) + }, + Entry("square album art", 600, 600, 5, 5), + Entry("small square", 1, 1, 5, 5), + Entry("landscape 16:9", 1920, 1080, 6, 4), + Entry("portrait 9:16", 1080, 1920, 4, 6), + Entry("extreme landscape capped at 9", 10000, 100, 9, 1), + Entry("zero width", 0, 600, 0, 0), + Entry("zero height", 600, 0, 0, 0), + ) +}) + +var _ = Describe("Encode", func() { + It("rejects out-of-range components", func() { + _, err := blurhash.Encode(solidImage(8, 8, color.NRGBA{A: 255}), 0, 5) + Expect(err).To(HaveOccurred()) + _, err = blurhash.Encode(solidImage(8, 8, color.NRGBA{A: 255}), 5, 10) + Expect(err).To(HaveOccurred()) + }) + + It("produces the spec-mandated length", func() { + // 1 (size flag) + 1 (max AC) + 4 (DC) + 2 per AC component + h, err := blurhash.Encode(solidImage(8, 8, color.NRGBA{R: 10, G: 20, B: 30, A: 255}), 4, 3) + Expect(err).ToNot(HaveOccurred()) + Expect(h).To(HaveLen(4 + 2 + 2*(4*3-1))) + }) + + It("encodes the size flag as the first character", func() { + h, err := blurhash.Encode(solidImage(8, 8, color.NRGBA{A: 255}), 4, 3) + Expect(err).ToNot(HaveOccurred()) + Expect(decode83(h[:1])).To(Equal((4 - 1) + (3-1)*9)) + }) + + It("stores the average color in the DC component", func() { + h, err := blurhash.Encode(solidImage(16, 16, color.NRGBA{R: 200, G: 100, B: 50, A: 255}), 4, 3) + Expect(err).ToNot(HaveOccurred()) + dc := decode83(h[2:6]) + Expect(dc >> 16).To(BeNumerically("~", 200, 1)) + Expect((dc >> 8) & 0xFF).To(BeNumerically("~", 100, 1)) + Expect(dc & 0xFF).To(BeNumerically("~", 50, 1)) + }) + + It("is deterministic", func() { + img := gradientImage(64, 64) + h1, err1 := blurhash.Encode(img, 5, 5) + h2, err2 := blurhash.Encode(img, 5, 5) + Expect(err1).ToNot(HaveOccurred()) + Expect(err2).ToNot(HaveOccurred()) + Expect(h1).To(Equal(h2)) + }) + + It("produces different hashes for different images", func() { + h1, _ := blurhash.Encode(solidImage(16, 16, color.NRGBA{R: 255, A: 255}), 4, 4) + h2, _ := blurhash.Encode(gradientImage(16, 16), 4, 4) + Expect(h1).ToNot(Equal(h2)) + }) + + It("downscales large images internally without changing the result materially", func() { + // A 1000px solid image must encode fine and carry the same DC as its small version. + big, err := blurhash.Encode(solidImage(1000, 1000, color.NRGBA{R: 60, G: 120, B: 180, A: 255}), 5, 5) + Expect(err).ToNot(HaveOccurred()) + small, err := blurhash.Encode(solidImage(16, 16, color.NRGBA{R: 60, G: 120, B: 180, A: 255}), 5, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(big[2:6]).To(Equal(small[2:6])) + }) +}) From 967de74bf724eef5699b5806d793a7c9c48f8ea3 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 09:10:31 -0400 Subject: [PATCH 23/87] feat(artwork): add worker-side artwork resolvers --- core/artwork/resolve.go | 332 ++++++++++++++++++++++++++++++ core/artwork/resolve_test.go | 305 +++++++++++++++++++++++++++ tests/mock_playlist_track_repo.go | 8 + 3 files changed, 645 insertions(+) create mode 100644 core/artwork/resolve.go create mode 100644 core/artwork/resolve_test.go diff --git a/core/artwork/resolve.go b/core/artwork/resolve.go new file mode 100644 index 000000000..bd314e37d --- /dev/null +++ b/core/artwork/resolve.go @@ -0,0 +1,332 @@ +package artwork + +import ( + "bytes" + "context" + "errors" + "fmt" + "image" + "image/draw" + "image/png" + "io" + "io/fs" + "os" + "strings" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/ffmpeg" + "github.com/navidrome/navidrome/model" +) + +// resolution is one attempted acquisition outcome for an entity. +type resolution struct { + reader io.ReadCloser // nil when no source yielded an image + source string // model.ItemArtwork.Source value: "folder", "embedded", "external", "upload", "generated" + sourcePath string // backing library/upload file (folder/upload: the image; embedded: the audio file); "" otherwise + refMtime int64 // mtime of sourcePath at resolution time; 0 when no sourcePath + extError bool // an external source errored/timed out (forces failed, never absent) +} + +// extGateFunc is an alias for the external-step wrapper Task 4 injects (rate +// limiter + circuit breaker); resolveItem defaults to a plain passthrough. +type extGateFunc = func(func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) + +func passthroughExtGate(f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { + return f() +} + +// resolveItem walks the kind's priority chain and returns the first hit. +func resolveItem(ctx context.Context, ds model.DataStore, prov external.Provider, ffmpeg ffmpeg.FFmpeg, item model.ArtworkQueueItem, extGate func(func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error)) (resolution, error) { + if extGate == nil { + extGate = passthroughExtGate + } + switch item.ItemKind { + case "al": + return resolveAlbum(ctx, ds, prov, ffmpeg, item.ItemID, extGate) + case "ar": + return resolveArtist(ctx, ds, prov, ffmpeg, item.ItemID, extGate) + case "pl": + return resolvePlaylist(ctx, ds, prov, ffmpeg, item.ItemID, extGate) + case "ra": + return resolveRadio(ctx, ds, item.ItemID) + default: + return resolution{}, fmt.Errorf("resolveItem: kind %q is not resolvable by the worker", item.ItemKind) + } +} + +// resolveAlbum ports the folder/embedded/external selection from +// reader_album.go, walking conf.Server.CoverArtPriority. +func resolveAlbum(ctx context.Context, ds model.DataStore, prov external.Provider, ffm ffmpeg.FFmpeg, albumID string, extGate extGateFunc) (resolution, error) { + al, err := ds.Album(ctx).Get(albumID) + if err != nil { + return resolution{}, err + } + _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, *al) + if err != nil { + return resolution{}, err + } + lib, err := loadLibraryView(ctx, ds, al.LibraryID) + if err != nil { + return resolution{}, err + } + + var extErr bool + for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.CoverArtPriority), ",") { + pattern = strings.TrimSpace(pattern) + switch { + case pattern == "embedded": + if res, ok := resolveEmbedded(ctx, lib, ffm, al.EmbedArtPath); ok { + return res, nil + } + case pattern == "external": + r, path, err := extGate(fromAlbumExternalSource(ctx, *al, prov)) + if r != nil { + return resolution{reader: r, source: "external", sourcePath: path}, nil + } + if err != nil && !errors.Is(err, model.ErrNotFound) { + extErr = true + } + case len(imgFiles) > 0: + if res, ok := resolveFolderFile(ctx, lib, imgFiles, pattern); ok { + return res, nil + } + } + } + return resolution{extError: extErr}, nil +} + +// resolveArtist ports the upload/folder/external selection from +// reader_artist.go: upload always wins, then conf.Server.ArtistArtPriority. +func resolveArtist(ctx context.Context, ds model.DataStore, prov external.Provider, ffm ffmpeg.FFmpeg, artistID string, extGate extGateFunc) (resolution, error) { + ar, err := ds.Artist(ctx).Get(artistID) + if err != nil { + return resolution{}, err + } + if res, ok := resolveLocalFile(ar.UploadedImagePath(), "upload"); ok { + return res, nil + } + + // Only consider albums where the artist is the sole album artist, same as reader_artist.go. + als, err := ds.Album(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.And{ + squirrel.Eq{"album_artist_id": artistID}, + squirrel.Eq{"json_array_length(participants, '$.albumartist')": 1}, + }, + }) + if err != nil { + return resolution{}, err + } + albumPaths, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, als...) + if err != nil { + return resolution{}, err + } + artistFolder, _, err := loadArtistFolder(ctx, ds, als, albumPaths) + if err != nil { + return resolution{}, err + } + var lib libraryView + if len(als) > 0 { + lib, err = loadLibraryView(ctx, ds, als[0].LibraryID) + if err != nil { + return resolution{}, err + } + } + + var extErr bool + for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.ArtistArtPriority), ",") { + pattern = strings.TrimSpace(pattern) + switch { + case pattern == "external": + r, path, err := extGate(fromArtistExternalSource(ctx, *ar, prov)) + if r != nil { + return resolution{reader: r, source: "external", sourcePath: path}, nil + } + if err != nil && !errors.Is(err, model.ErrNotFound) { + extErr = true + } + case pattern == "image-folder": + if res, ok := resolveArtistImageFolder(ar); ok { + return res, nil + } + case strings.HasPrefix(pattern, "album/"): + if lib.FS == nil { + continue + } + if res, ok := resolveFolderFile(ctx, lib, imgFiles, strings.TrimPrefix(pattern, "album/")); ok { + return res, nil + } + default: + if lib.FS == nil || artistFolder == "" { + continue + } + if res, ok := resolveArtistFolderPattern(ctx, lib, artistFolder, pattern); ok { + return res, nil + } + } + } + return resolution{extError: extErr}, nil +} + +// resolvePlaylist ports the 2x2 generated grid from reader_playlist.go, +// sourcing tiles through resolveAlbum instead of the old cached reader. +func resolvePlaylist(ctx context.Context, ds model.DataStore, prov external.Provider, ffm ffmpeg.FFmpeg, playlistID string, extGate extGateFunc) (resolution, error) { + pl, err := ds.Playlist(ctx).Get(playlistID) + if err != nil { + return resolution{}, err + } + albumIDs, err := ds.Playlist(ctx).Tracks(pl.ID, false).GetAlbumIDs(model.QueryOptions{Max: 4, Sort: "random()"}) + if err != nil { + return resolution{}, err + } + + var tiles []image.Image + var extErr bool + for _, albumID := range albumIDs { + res, err := resolveAlbum(ctx, ds, prov, ffm, albumID, extGate) + if err != nil { + continue + } + if res.extError { + extErr = true + } + if res.reader == nil { + continue + } + tile, decErr := decodeTile(res.reader) + res.reader.Close() + if decErr == nil { + tiles = append(tiles, tile) + } + if len(tiles) == 4 { + break + } + } + // Grow to 4 tiles by repeating what we have, mirroring reader_playlist.go's loadTiles. + switch len(tiles) { + case 0: + return resolution{extError: extErr}, nil + case 2: + tiles = append(tiles, tiles[1], tiles[0]) + case 3: + tiles = append(tiles, tiles[0]) + } + r, err := assembleTiles(tiles) + if err != nil { + return resolution{extError: extErr}, nil //nolint:nilerr // encode failure is a soft "no image", not a resolveItem error + } + return resolution{reader: r, source: "generated"}, nil +} + +// resolveRadio ports reader_radio.go: only an uploaded image, no fallback. +func resolveRadio(ctx context.Context, ds model.DataStore, radioID string) (resolution, error) { + r, err := ds.Radio(ctx).Get(radioID) + if err != nil { + return resolution{}, err + } + res, _ := resolveLocalFile(r.UploadedImagePath(), "upload") + return res, nil +} + +func resolveEmbedded(ctx context.Context, lib libraryView, ffm ffmpeg.FFmpeg, embedRel string) (resolution, bool) { + if embedRel == "" { + return resolution{}, false + } + abs := lib.Abs(embedRel) + mtime := mtimeViaFS(lib.FS, embedRel) + if r, _, _ := fromTag(ctx, lib.FS, embedRel)(); r != nil { + return resolution{reader: r, source: "embedded", sourcePath: abs, refMtime: mtime}, true + } + if r, _, _ := fromFFmpegTag(ctx, ffm, abs)(); r != nil { + return resolution{reader: r, source: "embedded", sourcePath: abs, refMtime: mtime}, true + } + return resolution{}, false +} + +func resolveFolderFile(ctx context.Context, lib libraryView, imgFiles []string, pattern string) (resolution, bool) { + r, path, _ := fromExternalFile(ctx, lib.FS, imgFiles, pattern)() + if r == nil { + return resolution{}, false + } + return resolution{reader: r, source: "folder", sourcePath: lib.Abs(path), refMtime: mtimeViaFS(lib.FS, path)}, true +} + +func resolveArtistImageFolder(ar *model.Artist) (resolution, bool) { + folder := conf.Server.ArtistImageFolder + if folder == "" { + return resolution{}, false + } + return resolveLocalFile(findImageInArtistFolder(folder, ar.MbzArtistID, ar.Name), "folder") +} + +func resolveArtistFolderPattern(ctx context.Context, lib libraryView, artistFolder, pattern string) (resolution, bool) { + r, path, _ := fromArtistFolder(ctx, lib.FS, lib.absRoot, artistFolder, pattern)() + if r == nil { + return resolution{}, false + } + return resolution{reader: r, source: "folder", sourcePath: path, refMtime: mtimeOf(path)}, true +} + +// resolveLocalFile opens an absolute path directly (uploads, image-folder). A +// missing or unreadable path is "no source", not an error. +func resolveLocalFile(path, source string) (resolution, bool) { + if path == "" { + return resolution{}, false + } + f, err := os.Open(path) + if err != nil { + return resolution{}, false + } + return resolution{reader: f, source: source, sourcePath: path, refMtime: mtimeOf(path)}, true +} + +func mtimeOf(path string) int64 { + info, err := os.Stat(path) + if err != nil { + return 0 + } + return info.ModTime().Unix() +} + +// mtimeViaFS stats through the library FS instead of a joined absolute path, +// since library roots in tests may not be real OS paths (e.g. testfile://). +func mtimeViaFS(fsys fs.FS, name string) int64 { + if fsys == nil || name == "" { + return 0 + } + info, err := fs.Stat(fsys, name) + if err != nil { + return 0 + } + return info.ModTime().Unix() +} + +// decodeTile and assembleTiles mirror playlistArtworkReader's createTile/ +// createTiledImage, reusing the same rect/fillCenter cropping helpers. +func decodeTile(r io.ReadCloser) (image.Image, error) { + img, _, err := image.Decode(r) + if err != nil { + return nil, err + } + return fillCenter(img, tileSize/2, tileSize/2), nil +} + +func assembleTiles(tiles []image.Image) (io.ReadCloser, error) { + buf := new(bytes.Buffer) + var err error + if len(tiles) == 4 { + rgba := image.NewRGBA(image.Rectangle{Max: image.Point{X: tileSize - 1, Y: tileSize - 1}}) + draw.Draw(rgba, rect(0), tiles[0], image.Point{}, draw.Src) + draw.Draw(rgba, rect(1), tiles[1], image.Point{}, draw.Src) + draw.Draw(rgba, rect(2), tiles[2], image.Point{}, draw.Src) + draw.Draw(rgba, rect(3), tiles[3], image.Point{}, draw.Src) + err = png.Encode(buf, rgba) + } else { + err = png.Encode(buf, tiles[0]) + } + if err != nil { + return nil, err + } + return io.NopCloser(buf), nil +} diff --git a/core/artwork/resolve_test.go b/core/artwork/resolve_test.go new file mode 100644 index 000000000..c3fda1f1d --- /dev/null +++ b/core/artwork/resolve_test.go @@ -0,0 +1,305 @@ +package artwork + +import ( + "context" + "errors" + "image" + "io" + "net/url" + "os" + "path/filepath" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// fakeExternalProvider is a minimal external.Provider stub for resolve_test.go; +// only AlbumImage/ArtistImage are exercised by the resolvers. +type fakeExternalProvider struct { + external.Provider + albumImage func(ctx context.Context, id string) (*url.URL, error) + artistImage func(ctx context.Context, id string) (*url.URL, error) +} + +func (f *fakeExternalProvider) AlbumImage(ctx context.Context, id string) (*url.URL, error) { + if f.albumImage != nil { + return f.albumImage(ctx, id) + } + return nil, model.ErrNotFound +} + +func (f *fakeExternalProvider) ArtistImage(ctx context.Context, id string) (*url.URL, error) { + if f.artistImage != nil { + return f.artistImage(ctx, id) + } + return nil, model.ErrNotFound +} + +var _ = Describe("resolveItem", func() { + var ( + ctx context.Context + ds *tests.MockDataStore + folderRepo *fakeFolderRepo + libRepo *tests.MockLibraryRepo + ffm *tests.MockFFmpeg + prov *fakeExternalProvider + repoRoot string + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + ctx = context.Background() + var err error + repoRoot, err = os.Getwd() + Expect(err).ToNot(HaveOccurred()) + + folderRepo = &fakeFolderRepo{} + libRepo = &tests.MockLibraryRepo{} + libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) + ffm = tests.NewMockFFmpeg("") + prov = &fakeExternalProvider{} + ds = &tests.MockDataStore{ + MockedFolder: folderRepo, + MockedLibrary: libRepo, + } + }) + + Describe("kind dispatch", func() { + It("returns an error for kinds the worker never enqueues", func() { + _, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "x"}, nil) + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("album", func() { + BeforeEach(func() { + conf.Server.CoverArtPriority = "cover.jpg, embedded" + ds.MockedAlbum = tests.CreateMockAlbumRepo() + }) + + It("resolves folder art from the library FS", func() { + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }} + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al1", Name: "Album", FolderIDs: []string{"f1"}}, + }) + + res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"}, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(res.source).To(Equal("folder")) + Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/cover.jpg")) + Expect(res.refMtime).To(BeNumerically(">", 0)) + Expect(res.extError).To(BeFalse()) + }) + + It("falls back to embedded art when no folder image matches", func() { + folderRepo.result = nil + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al2", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}, + }) + + res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"}, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(res.source).To(Equal("embedded")) + Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/test.mp3")) + Expect(res.refMtime).To(BeNumerically(">", 0)) + }) + + It("sets extError when the external source errors without being not-found", func() { + conf.Server.CoverArtPriority = "external" + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al3", Name: "Album"}, + }) + prov.albumImage = func(context.Context, string) (*url.URL, error) { + return nil, errors.New("agent timed out") + } + + res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"}, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).To(BeNil()) + Expect(res.extError).To(BeTrue()) + }) + + It("does not set extError when the external source reports not-found", func() { + conf.Server.CoverArtPriority = "external" + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al4", Name: "Album"}, + }) + // prov.albumImage left nil -> fakeExternalProvider returns model.ErrNotFound + + res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"}, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).To(BeNil()) + Expect(res.extError).To(BeFalse()) + }) + + It("routes the external step through a custom extGate", func() { + conf.Server.CoverArtPriority = "external" + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al5", Name: "Album"}, + }) + prov.albumImage = func(context.Context, string) (*url.URL, error) { + return nil, errors.New("boom") + } + var extGateCalls int + extGate := func(f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { + extGateCalls++ + return f() + } + + res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al5"}, extGate) + Expect(err).ToNot(HaveOccurred()) + Expect(res.extError).To(BeTrue()) + Expect(extGateCalls).To(Equal(1)) + }) + }) + + Describe("artist", func() { + It("resolves the uploaded image before any priority chain lookup", func() { + tmpDir := GinkgoT().TempDir() + conf.Server.DataFolder = conf.NewDir(tmpDir) + Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "artist"), 0755)).To(Succeed()) + imgPath := filepath.Join(tmpDir, "artwork", "artist", "ar1_test.jpg") + Expect(os.WriteFile(imgPath, []byte("uploaded artist image"), 0600)).To(Succeed()) + + artistRepo := tests.CreateMockArtistRepo() + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist", UploadedImage: "ar1_test.jpg"}}) + ds.MockedArtist = artistRepo + + res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar1"}, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(res.source).To(Equal("upload")) + Expect(res.sourcePath).To(Equal(imgPath)) + }) + + It("falls through to the ArtistArtPriority chain when there is no upload", func() { + conf.Server.ArtistArtPriority = "album/artist.*" + folderRepo.result = []model.Folder{{ + LibraryPath: testFileLibPath(repoRoot), + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"artist.png"}, + }} + artistRepo := tests.CreateMockArtistRepo() + artistRepo.SetData(model.Artists{{ID: "ar2", Name: "Artist"}}) + ds.MockedArtist = artistRepo + ds.MockedAlbum = tests.CreateMockAlbumRepo() + ds.MockedAlbum.(*tests.MockAlbumRepo).All = model.Albums{ + {ID: "al9", Name: "Album", LibraryID: 0, FolderIDs: []string{"f1"}}, + } + + res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar2"}, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(res.source).To(Equal("folder")) + Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/artist.png")) + }) + }) + + Describe("radio", func() { + It("yields an empty resolution when there is no uploaded image", func() { + tmpDir := GinkgoT().TempDir() + conf.Server.DataFolder = conf.NewDir(tmpDir) + + radioRepo := tests.CreateMockedRadioRepo() + radioRepo.Data = map[string]*model.Radio{"ra1": {ID: "ra1", Name: "Radio"}} + ds.MockedRadio = radioRepo + + res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra1"}, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(res).To(Equal(resolution{})) + }) + + It("resolves the uploaded image when set", func() { + tmpDir := GinkgoT().TempDir() + conf.Server.DataFolder = conf.NewDir(tmpDir) + Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "radio"), 0755)).To(Succeed()) + imgPath := filepath.Join(tmpDir, "artwork", "radio", "ra2_test.jpg") + Expect(os.WriteFile(imgPath, []byte("uploaded radio image"), 0600)).To(Succeed()) + + radioRepo := tests.CreateMockedRadioRepo() + radioRepo.Data = map[string]*model.Radio{"ra2": {ID: "ra2", Name: "Radio", UploadedImage: "ra2_test.jpg"}} + ds.MockedRadio = radioRepo + + res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra2"}, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(res.source).To(Equal("upload")) + Expect(res.sourcePath).To(Equal(imgPath)) + }) + }) + + Describe("playlist", func() { + BeforeEach(func() { + conf.Server.CoverArtPriority = "cover.jpg" + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }} + ds.MockedAlbum = tests.CreateMockAlbumRepo() + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "t1", Name: "T1", FolderIDs: []string{"f1"}}, + {ID: "t2", Name: "T2", FolderIDs: []string{"f1"}}, + {ID: "t3", Name: "T3", FolderIDs: []string{"f1"}}, + {ID: "t4", Name: "T4", FolderIDs: []string{"f1"}}, + }) + }) + + DescribeTable("yields a generated grid from up to 4 album tiles", + func(albumIDs []string, expectedSize int) { + plRepo := tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "pl1", Name: "Playlist"}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: albumIDs} + ds.MockedPlaylist = plRepo + + res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl1"}, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(res.source).To(Equal("generated")) + + img, format, err := image.Decode(res.reader) + Expect(err).ToNot(HaveOccurred()) + Expect(format).To(Equal("png")) + Expect(img.Bounds().Dx()).To(Equal(expectedSize)) + Expect(img.Bounds().Dy()).To(Equal(expectedSize)) + }, + // tileSize-1: the 4-tile canvas is built as [0, tileSize-1], matching + // reader_playlist.go's createTiledImage exactly. + Entry("1 album -> single tile", []string{"t1"}, tileSize/2), + Entry("2 albums -> duplicated to 4 tiles", []string{"t1", "t2"}, tileSize-1), + Entry("3 albums -> duplicated to 4 tiles", []string{"t1", "t2", "t3"}, tileSize-1), + Entry("4 albums -> full grid", []string{"t1", "t2", "t3", "t4"}, tileSize-1), + ) + + It("yields an empty resolution when no album has art", func() { + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "empty1", Name: "Empty"}, + }) + plRepo := tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "pl2", Name: "Playlist"}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"empty1"}} + ds.MockedPlaylist = plRepo + folderRepo.result = nil + + res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl2"}, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).To(BeNil()) + Expect(res.source).To(BeEmpty()) + }) + }) +}) diff --git a/tests/mock_playlist_track_repo.go b/tests/mock_playlist_track_repo.go index 2835baadd..83751bb28 100644 --- a/tests/mock_playlist_track_repo.go +++ b/tests/mock_playlist_track_repo.go @@ -14,6 +14,7 @@ type MockPlaylistTrackRepo struct { Reordered bool AddCount int Err error + AlbumIDs []string // stubbed result for GetAlbumIDs, ignoring options } func (m *MockPlaylistTrackRepo) SetData(tracks model.PlaylistTracks) { @@ -66,6 +67,13 @@ func (m *MockPlaylistTrackRepo) GetCursor(options ...model.QueryOptions) (model. }, nil } +func (m *MockPlaylistTrackRepo) GetAlbumIDs(...model.QueryOptions) ([]string, error) { + if m.Err != nil { + return nil, m.Err + } + return m.AlbumIDs, nil +} + func (m *MockPlaylistTrackRepo) GetMediaFileIDs(options ...model.QueryOptions) ([]string, error) { if m.Err != nil { return nil, m.Err From 608db503a747ce48044562699b46c56637b22322 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 09:22:08 -0400 Subject: [PATCH 24/87] fix(artwork): propagate playlist tile failures and dedupe external step --- core/artwork/resolve.go | 41 +++++++++++++++++------- core/artwork/resolve_test.go | 61 ++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 12 deletions(-) diff --git a/core/artwork/resolve.go b/core/artwork/resolve.go index bd314e37d..5aafdf5c5 100644 --- a/core/artwork/resolve.go +++ b/core/artwork/resolve.go @@ -81,11 +81,9 @@ func resolveAlbum(ctx context.Context, ds model.DataStore, prov external.Provide return res, nil } case pattern == "external": - r, path, err := extGate(fromAlbumExternalSource(ctx, *al, prov)) - if r != nil { - return resolution{reader: r, source: "external", sourcePath: path}, nil - } - if err != nil && !errors.Is(err, model.ErrNotFound) { + if res, ok, isErr := resolveExternalStep(extGate, fromAlbumExternalSource(ctx, *al, prov)); ok { + return res, nil + } else if isErr { extErr = true } case len(imgFiles) > 0: @@ -139,11 +137,9 @@ func resolveArtist(ctx context.Context, ds model.DataStore, prov external.Provid pattern = strings.TrimSpace(pattern) switch { case pattern == "external": - r, path, err := extGate(fromArtistExternalSource(ctx, *ar, prov)) - if r != nil { - return resolution{reader: r, source: "external", sourcePath: path}, nil - } - if err != nil && !errors.Is(err, model.ErrNotFound) { + if res, ok, isErr := resolveExternalStep(extGate, fromArtistExternalSource(ctx, *ar, prov)); ok { + return res, nil + } else if isErr { extErr = true } case pattern == "image-folder": @@ -183,9 +179,13 @@ func resolvePlaylist(ctx context.Context, ds model.DataStore, prov external.Prov var tiles []image.Image var extErr bool + var tileErr error // first internal (non-external) tile failure, e.g. album deleted mid-flight for _, albumID := range albumIDs { res, err := resolveAlbum(ctx, ds, prov, ffm, albumID, extGate) if err != nil { + if tileErr == nil { + tileErr = err + } continue } if res.extError { @@ -203,10 +203,16 @@ func resolvePlaylist(ctx context.Context, ds model.DataStore, prov external.Prov break } } + if len(tiles) == 0 { + // A tile-level failure must never resolve as a clean absent: propagate + // internal errors, and force extError for external ones. + if tileErr != nil { + return resolution{}, fmt.Errorf("resolvePlaylist: sampled album art failed: %w", tileErr) + } + return resolution{extError: extErr}, nil + } // Grow to 4 tiles by repeating what we have, mirroring reader_playlist.go's loadTiles. switch len(tiles) { - case 0: - return resolution{extError: extErr}, nil case 2: tiles = append(tiles, tiles[1], tiles[0]) case 3: @@ -229,6 +235,17 @@ func resolveRadio(ctx context.Context, ds model.DataStore, radioID string) (reso return res, nil } +// resolveExternalStep runs an external sourceFunc through extGate, shared by +// resolveAlbum and resolveArtist. ok reports a hit; extErr reports a +// non-not-found error (a not-found is a definitive "no", not a failure). +func resolveExternalStep(extGate extGateFunc, sf func() (io.ReadCloser, string, error)) (res resolution, ok bool, extErr bool) { + r, path, err := extGate(sf) + if r != nil { + return resolution{reader: r, source: "external", sourcePath: path}, true, false + } + return resolution{}, false, err != nil && !errors.Is(err, model.ErrNotFound) +} + func resolveEmbedded(ctx context.Context, lib libraryView, ffm ffmpeg.FFmpeg, embedRel string) (resolution, bool) { if embedRel == "" { return resolution{}, false diff --git a/core/artwork/resolve_test.go b/core/artwork/resolve_test.go index c3fda1f1d..c4ccc3733 100644 --- a/core/artwork/resolve_test.go +++ b/core/artwork/resolve_test.go @@ -207,6 +207,54 @@ var _ = Describe("resolveItem", func() { Expect(res.source).To(Equal("folder")) Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/artist.png")) }) + + It("sets extError when the external source errors without being not-found", func() { + conf.Server.ArtistArtPriority = "external" + artistRepo := tests.CreateMockArtistRepo() + artistRepo.SetData(model.Artists{{ID: "ar3", Name: "Artist"}}) + ds.MockedArtist = artistRepo + prov.artistImage = func(context.Context, string) (*url.URL, error) { + return nil, errors.New("agent timed out") + } + + res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar3"}, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).To(BeNil()) + Expect(res.extError).To(BeTrue()) + }) + + It("does not set extError when the external source reports not-found", func() { + conf.Server.ArtistArtPriority = "external" + artistRepo := tests.CreateMockArtistRepo() + artistRepo.SetData(model.Artists{{ID: "ar4", Name: "Artist"}}) + ds.MockedArtist = artistRepo + // prov.artistImage left nil -> fakeExternalProvider returns model.ErrNotFound + + res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar4"}, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).To(BeNil()) + Expect(res.extError).To(BeFalse()) + }) + + It("routes the external step through a custom extGate", func() { + conf.Server.ArtistArtPriority = "external" + artistRepo := tests.CreateMockArtistRepo() + artistRepo.SetData(model.Artists{{ID: "ar5", Name: "Artist"}}) + ds.MockedArtist = artistRepo + prov.artistImage = func(context.Context, string) (*url.URL, error) { + return nil, errors.New("boom") + } + var extGateCalls int + extGate := func(f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { + extGateCalls++ + return f() + } + + res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar5"}, extGate) + Expect(err).ToNot(HaveOccurred()) + Expect(res.extError).To(BeTrue()) + Expect(extGateCalls).To(Equal(1)) + }) }) Describe("radio", func() { @@ -301,5 +349,18 @@ var _ = Describe("resolveItem", func() { Expect(res.reader).To(BeNil()) Expect(res.source).To(BeEmpty()) }) + + It("does not resolve as absent when every sampled album fails to resolve", func() { + // "missing1"/"missing2" are not in MockAlbumRepo's data, so resolveAlbum + // returns a genuine (non-external) error for every sampled tile. + plRepo := tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "pl3", Name: "Playlist"}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"missing1", "missing2"}} + ds.MockedPlaylist = plRepo + + res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl3"}, nil) + Expect(err).To(HaveOccurred()) + Expect(res).To(Equal(resolution{})) + }) }) }) From e0655dc88263318cdfe1e6d834c3fbedf94dd733 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 09:36:12 -0400 Subject: [PATCH 25/87] feat(artwork): add acquisition processor Resolves one queue item end to end: hash/dedup, decode + 128px thumbnail blurhash, place bytes (store vs source file), and persist found/absent/ failed state for the worker (Task 4) to act on. --- core/artwork/artwork_suite_test.go | 8 ++ core/artwork/processor.go | 210 +++++++++++++++++++++++++++++ core/artwork/processor_test.go | 206 ++++++++++++++++++++++++++++ 3 files changed, 424 insertions(+) create mode 100644 core/artwork/processor.go create mode 100644 core/artwork/processor_test.go diff --git a/core/artwork/artwork_suite_test.go b/core/artwork/artwork_suite_test.go index d42d7f3e4..1fd04627a 100644 --- a/core/artwork/artwork_suite_test.go +++ b/core/artwork/artwork_suite_test.go @@ -15,9 +15,17 @@ import ( "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "go.uber.org/goleak" ) func TestArtwork(t *testing.T) { + // Only run goleak checks when the GOLEAK env var is set + if os.Getenv("GOLEAK") != "" { + defer goleak.VerifyNone(t, + goleak.IgnoreTopFunction("github.com/onsi/ginkgo/v2/internal/interrupt_handler.(*InterruptHandler).registerForInterrupts.func2"), + ) + } + tests.Init(t, false) log.SetLevel(log.LevelFatal) RegisterFailHandler(Fail) diff --git a/core/artwork/processor.go b/core/artwork/processor.go new file mode 100644 index 000000000..659f414c2 --- /dev/null +++ b/core/artwork/processor.go @@ -0,0 +1,210 @@ +package artwork + +import ( + "bytes" + "context" + "errors" + "fmt" + "image" + "image/draw" + "io" + "time" + + "github.com/navidrome/navidrome/core/artwork/blurhash" + "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/ffmpeg" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + xdraw "golang.org/x/image/draw" +) + +// outcome tells the worker what to do with the queue row: found/absent +// delete it, failed reschedules it via MarkFailed. +type outcome int + +const ( + outcomeFound outcome = iota + outcomeAbsent + outcomeFailed +) + +// thumbnailSize is the max dimension fed to blurhash, matching Jellyfin's own input cap. +const thumbnailSize = 128 + +// workerDeps are the collaborators processItem needs; extGate is nil outside +// tests, in which case resolveItem falls back to a plain passthrough. +type workerDeps struct { + ds model.DataStore + store *ImageStore + prov external.Provider + ffmpeg ffmpeg.FFmpeg + extGate func(func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) +} + +// processItem resolves one queue item end to end: find an image, hash/decode/ +// blurhash it, place its bytes, and persist the resulting state. +func processItem(ctx context.Context, deps *workerDeps, item model.ArtworkQueueItem) outcome { + repo := deps.ds.Artwork(ctx) + + res, err := resolveItem(ctx, deps.ds, deps.prov, deps.ffmpeg, item, deps.extGate) + if err != nil { + log.Warn(ctx, "artwork: could not resolve item", "kind", item.ItemKind, "id", item.ItemID, err) + return outcomeFailed + } + if res.reader == nil { + if res.extError { + // An external source errored/timed out: never settle on absent, keep serving old state. + return outcomeFailed + } + return writeAbsent(ctx, repo, item) + } + defer res.reader.Close() + + data, err := io.ReadAll(res.reader) + if err != nil { + log.Warn(ctx, "artwork: failed to read resolved image", "kind", item.ItemKind, "id", item.ItemID, err) + return outcomeFailed + } + log.Debug(ctx, "artwork: read resolved image", "kind", item.ItemKind, "id", item.ItemID, "source", res.source, "bytes", len(data)) + + hash, err := HashImage(bytes.NewReader(data)) + if err != nil { + log.Warn(ctx, "artwork: failed to hash image", "kind", item.ItemKind, "id", item.ItemID, err) + return outcomeFailed + } + + art, err := repo.GetImage(hash) + switch { + case err == nil: + // Dedup hit: identical bytes already known, reuse dims/mime/blurhash. + case errors.Is(err, model.ErrNotFound): + art, err = decodeArtwork(ctx, hash, data) + if err != nil { + log.Warn(ctx, "artwork: failed to decode resolved image", "kind", item.ItemKind, "id", item.ItemID, err) + return outcomeFailed + } + default: + log.Warn(ctx, "artwork: failed to look up image hash", "kind", item.ItemKind, "id", item.ItemID, err) + return outcomeFailed + } + art.SizeBytes = int64(len(data)) + + if err := placeBytes(deps.store, art, res, data); err != nil { + log.Warn(ctx, "artwork: failed to write image store", "kind", item.ItemKind, "id", item.ItemID, err) + return outcomeFailed + } + if err := repo.PutImage(art); err != nil { + log.Warn(ctx, "artwork: failed to persist artwork image", "kind", item.ItemKind, "id", item.ItemID, err) + return outcomeFailed + } + if err := repo.PutItemArtwork(&model.ItemArtwork{ + ItemKind: item.ItemKind, + ItemID: item.ItemID, + ImageType: item.ImageType, + Hash: hash, + Source: res.source, + AttemptedAt: time.Now(), + }); err != nil { + log.Warn(ctx, "artwork: failed to persist item artwork state", "kind", item.ItemKind, "id", item.ItemID, err) + return outcomeFailed + } + return outcomeFound +} + +// writeAbsent records a known-absent state: every local/external source answered definitively "no". +func writeAbsent(ctx context.Context, repo model.ArtworkRepository, item model.ArtworkQueueItem) outcome { + err := repo.PutItemArtwork(&model.ItemArtwork{ + ItemKind: item.ItemKind, + ItemID: item.ItemID, + ImageType: item.ImageType, + AttemptedAt: time.Now(), + }) + if err != nil { + log.Warn(ctx, "artwork: failed to persist absent state", "kind", item.ItemKind, "id", item.ItemID, err) + return outcomeFailed + } + return outcomeAbsent +} + +// decodeArtwork builds a new Artwork row from raw bytes: dimensions, mime and a +// blurhash computed from a downscaled thumbnail. +func decodeArtwork(ctx context.Context, hash string, data []byte) (*model.Artwork, error) { + cfg, format, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("decode image config: %w", err) + } + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("decode image: %w", err) + } + + thumb := makeThumbnail(img, thumbnailSize) + xComp, yComp := blurhash.Components(thumb.Bounds().Dx(), thumb.Bounds().Dy()) + bh, err := blurhash.Encode(thumb, xComp, yComp) + if err != nil { + log.Warn(ctx, "artwork: blurhash encoding failed", "hash", hash, err) + bh = "" + } + + return &model.Artwork{ + Hash: hash, + Mime: mimeForFormat(format), + Width: cfg.Width, + Height: cfg.Height, + BlurHash: bh, + }, nil +} + +// makeThumbnail downscales img to fit within maxSize on its longest side, +// reusing reader_resized.go's fast-scale-type + CatmullRom approach. Images +// already within bounds are returned as-is (no upscaling). +func makeThumbnail(img image.Image, maxSize int) image.Image { + b := img.Bounds() + w, h := b.Dx(), b.Dy() + if w <= maxSize && h <= maxSize { + return toFastScaleType(img) + } + scale := float64(maxSize) / float64(max(w, h)) + dst := image.NewRGBA(image.Rect(0, 0, max(1, int(float64(w)*scale)), max(1, int(float64(h)*scale)))) + xdraw.CatmullRom.Scale(dst, dst.Bounds(), toFastScaleType(img), b, draw.Src, nil) + return dst +} + +// isFileBacked reports whether a resolution's bytes already live in a library/upload +// file, so the acquisition must not duplicate them into the content-addressed store. +func isFileBacked(source string) bool { + return source == "folder" || source == "upload" +} + +// placeBytes fills in art's SourcePath/RefMtime and, for sources with no library file +// backing them, writes the bytes into the store (embedded keeps its audio file provenance). +func placeBytes(store *ImageStore, art *model.Artwork, res resolution, data []byte) error { + if isFileBacked(res.source) { + art.SourcePath = res.sourcePath + art.RefMtime = res.refMtime + return nil + } + art.SourcePath = "" + art.RefMtime = 0 + if res.source == "embedded" { + art.SourcePath = res.sourcePath + art.RefMtime = res.refMtime + } + return store.Write(art.Hash, art.Mime, bytes.NewReader(data)) +} + +// mimeForFormat maps an image.Decode format name to its MIME type; extForMime +// in image_store.go performs the inverse for content-addressed file paths. +func mimeForFormat(format string) string { + switch format { + case "jpeg": + return "image/jpeg" + case "png": + return "image/png" + case "gif": + return "image/gif" + case "webp": + return "image/webp" + } + return "application/octet-stream" +} diff --git a/core/artwork/processor_test.go b/core/artwork/processor_test.go new file mode 100644 index 000000000..a73cd4fca --- /dev/null +++ b/core/artwork/processor_test.go @@ -0,0 +1,206 @@ +package artwork + +import ( + "context" + "errors" + "net/url" + "os" + "path/filepath" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("processItem", func() { + var ( + ctx context.Context + ds *tests.MockDataStore + folderRepo *fakeFolderRepo + libRepo *tests.MockLibraryRepo + ffm *tests.MockFFmpeg + prov *fakeExternalProvider + store *ImageStore + artRepo *tests.MockArtworkRepo + repoRoot string + deps *workerDeps + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + ctx = context.Background() + var err error + repoRoot, err = os.Getwd() + Expect(err).ToNot(HaveOccurred()) + + folderRepo = &fakeFolderRepo{} + libRepo = &tests.MockLibraryRepo{} + libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) + ffm = tests.NewMockFFmpeg("") + prov = &fakeExternalProvider{} + artRepo = tests.CreateMockArtworkRepo() + ds = &tests.MockDataStore{ + MockedFolder: folderRepo, + MockedLibrary: libRepo, + MockedArtwork: artRepo, + } + ds.MockedAlbum = tests.CreateMockAlbumRepo() + store = NewImageStore(GinkgoT().TempDir()) + deps = &workerDeps{ds: ds, store: store, prov: prov, ffmpeg: ffm} + + conf.Server.CoverArtPriority = "cover.jpg, embedded" + }) + + It("found-folder: persists state from a folder image, writes no store file, keeps sourcePath/refMtime", func() { + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }} + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al1", Name: "Album", FolderIDs: []string{"f1"}}, + }) + + out := processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"}) + Expect(out).To(Equal(outcomeFound)) + + ia, err := artRepo.GetItemArtwork("al", "al1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Hash).ToNot(BeEmpty()) + Expect(ia.Source).To(Equal("folder")) + + art, err := artRepo.GetImage(ia.Hash) + Expect(err).ToNot(HaveOccurred()) + Expect(filepath.ToSlash(art.SourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/cover.jpg")) + Expect(art.RefMtime).To(BeNumerically(">", 0)) + + _, err = store.Open(ia.Hash, art.Mime) + Expect(os.IsNotExist(err)).To(BeTrue(), "folder-backed art must not be duplicated into the store") + }) + + It("found-embedded: writes a store file and computes a non-empty blurhash from a real fixture", func() { + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al2", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}, + }) + folderRepo.result = nil + + out := processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"}) + Expect(out).To(Equal(outcomeFound)) + + ia, err := artRepo.GetItemArtwork("al", "al2", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("embedded")) + + art, err := artRepo.GetImage(ia.Hash) + Expect(err).ToNot(HaveOccurred()) + Expect(art.BlurHash).ToNot(BeEmpty()) + Expect(filepath.ToSlash(art.SourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/test.mp3")) + + rc, err := store.Open(ia.Hash, art.Mime) + Expect(err).ToNot(HaveOccurred()) + rc.Close() + }) + + It("absent: no local source and no external error persists a known-absent state", func() { + folderRepo.result = nil + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al3", Name: "Album"}, + }) + + out := processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"}) + Expect(out).To(Equal(outcomeAbsent)) + + ia, err := artRepo.GetItemArtwork("al", "al3", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Hash).To(BeEmpty()) + Expect(ia.Source).To(BeEmpty()) + Expect(ia.AttemptedAt).To(BeTemporally("~", time.Now(), time.Second)) + }) + + It("failed-on-extError: leaves the item's state untouched", func() { + conf.Server.CoverArtPriority = "external" + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al4", Name: "Album"}, + }) + prov.albumImage = func(context.Context, string) (*url.URL, error) { + return nil, errors.New("agent timed out") + } + + out := processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"}) + Expect(out).To(Equal(outcomeFailed)) + + _, err := artRepo.GetItemArtwork("al", "al4", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("dedup: a second item with identical bytes skips decode and reuses the artwork row", func() { + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }} + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al5", Name: "Album A", FolderIDs: []string{"f1"}}, + {ID: "al6", Name: "Album B", FolderIDs: []string{"f1"}}, + }) + + out1 := processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al5"}) + Expect(out1).To(Equal(outcomeFound)) + ia1, err := artRepo.GetItemArtwork("al", "al5", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + + // Poison the stored blurhash: if the second item re-decodes instead of + // deduping on hash, this sentinel gets overwritten by a real computed value. + poisoned := artRepo.Data[ia1.Hash] + poisoned.BlurHash = "SENTINEL" + artRepo.Data[ia1.Hash] = poisoned + + out2 := processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al6"}) + Expect(out2).To(Equal(outcomeFound)) + ia2, err := artRepo.GetItemArtwork("al", "al6", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia2.Hash).To(Equal(ia1.Hash)) + + reused, err := artRepo.GetImage(ia1.Hash) + Expect(err).ToNot(HaveOccurred()) + Expect(reused.BlurHash).To(Equal("SENTINEL")) + }) + + It("decode failure on found bytes: fails without writing state", func() { + tmpDir := GinkgoT().TempDir() + conf.Server.DataFolder = conf.NewDir(tmpDir) + Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "radio"), 0755)).To(Succeed()) + imgPath := filepath.Join(tmpDir, "artwork", "radio", "ra1_test.jpg") + Expect(os.WriteFile(imgPath, []byte("not actually an image"), 0600)).To(Succeed()) + + radioRepo := tests.CreateMockedRadioRepo() + radioRepo.Data = map[string]*model.Radio{"ra1": {ID: "ra1", Name: "Radio", UploadedImage: "ra1_test.jpg"}} + ds.MockedRadio = radioRepo + + out := processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra1"}) + Expect(out).To(Equal(outcomeFailed)) + + _, err := artRepo.GetItemArtwork("ra", "ra1", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("store write failure: fails without writing state", func() { + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al7", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}, + }) + folderRepo.result = nil + + // A store root that is a plain file makes every MkdirAll under it fail. + blockedRoot := filepath.Join(GinkgoT().TempDir(), "not-a-dir") + Expect(os.WriteFile(blockedRoot, []byte("x"), 0600)).To(Succeed()) + deps.store = NewImageStore(blockedRoot) + + out := processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al7"}) + Expect(out).To(Equal(outcomeFailed)) + + _, err := artRepo.GetItemArtwork("al", "al7", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + }) +}) From d6fc829f841e87bfaa1bba1cc4bd5cde6532c307 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 09:45:54 -0400 Subject: [PATCH 26/87] style(artwork): tighten processor comments to budget --- core/artwork/processor.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/core/artwork/processor.go b/core/artwork/processor.go index 659f414c2..0c4d6c520 100644 --- a/core/artwork/processor.go +++ b/core/artwork/processor.go @@ -28,7 +28,7 @@ const ( outcomeFailed ) -// thumbnailSize is the max dimension fed to blurhash, matching Jellyfin's own input cap. +// thumbnailSize is the max dimension fed to blurhash. const thumbnailSize = 128 // workerDeps are the collaborators processItem needs; extGate is nil outside @@ -155,9 +155,8 @@ func decodeArtwork(ctx context.Context, hash string, data []byte) (*model.Artwor }, nil } -// makeThumbnail downscales img to fit within maxSize on its longest side, -// reusing reader_resized.go's fast-scale-type + CatmullRom approach. Images -// already within bounds are returned as-is (no upscaling). +// makeThumbnail downscales img to fit within maxSize on its longest side. +// Images within bounds are returned as-is (no upscaling). func makeThumbnail(img image.Image, maxSize int) image.Image { b := img.Bounds() w, h := b.Dx(), b.Dy() From 57c64e386a647b619afd2431ba35aad4b261b7e2 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 10:01:47 -0400 Subject: [PATCH 27/87] feat(artwork): add acquisition worker service --- cmd/root.go | 10 ++ cmd/wire_gen.go | 15 ++ cmd/wire_injectors.go | 6 + conf/configuration.go | 4 + core/artwork/image_store.go | 8 + core/artwork/prune.go | 4 +- core/artwork/wire_providers.go | 2 + core/artwork/worker.go | 250 +++++++++++++++++++++++++++++ core/artwork/worker_test.go | 188 ++++++++++++++++++++++ core/artwork/worker_timing_test.go | 95 +++++++++++ 10 files changed, 580 insertions(+), 2 deletions(-) create mode 100644 core/artwork/worker.go create mode 100644 core/artwork/worker_test.go create mode 100644 core/artwork/worker_timing_test.go diff --git a/cmd/root.go b/cmd/root.go index 9e2b38cd8..2b64ab850 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -88,6 +88,7 @@ func runNavidrome(ctx context.Context) { g.Go(startInsightsCollector(ctx)) g.Go(scheduleDBAnalyzer(ctx)) g.Go(startPluginManager(ctx)) + g.Go(startArtworkWorker(ctx)) g.Go(runInitialScan(ctx)) if conf.Server.Scanner.Enabled { g.Go(startScanWatcher(ctx)) @@ -344,6 +345,15 @@ func startPlaybackServer(ctx context.Context) func() error { } } +// startArtworkWorker starts the background artwork acquisition worker. It always +// runs; the queue is simply empty until something enqueues work into it. +func startArtworkWorker(ctx context.Context) func() error { + return func() error { + log.Info(ctx, "Starting artwork worker") + return CreateArtworkWorker().Run(ctx) + } +} + // startPluginManager starts the plugin manager, if configured. func startPluginManager(ctx context.Context) func() error { return func() error { diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index c4a797ef4..44c4cb46d 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -236,6 +236,21 @@ func GetPlaybackServer() playback.PlaybackServer { return playbackServer } +func CreateArtworkWorker() *artwork.Worker { + sqlDB := db.Db() + dataStore := persistence.New(sqlDB) + imageStore := artwork.ProvideImageStore() + broker := events.GetBroker() + metricsMetrics := metrics.GetPrometheusInstance(dataStore) + manager := plugins.GetManager(dataStore, broker, metricsMetrics) + agentsAgents := agents.GetAgents(dataStore, manager) + matcherMatcher := matcher.New(dataStore) + provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) + fFmpeg := ffmpeg.New() + worker := artwork.NewWorker(dataStore, imageStore, provider, fFmpeg) + return worker +} + func getPluginManager() *plugins.Manager { sqlDB := db.Db() dataStore := persistence.New(sqlDB) diff --git a/cmd/wire_injectors.go b/cmd/wire_injectors.go index 94faa5af3..a835e5c4c 100644 --- a/cmd/wire_injectors.go +++ b/cmd/wire_injectors.go @@ -136,6 +136,12 @@ func GetPlaybackServer() playback.PlaybackServer { )) } +func CreateArtworkWorker() *artwork.Worker { + panic(wire.Build( + allProviders, + )) +} + func getPluginManager() *plugins.Manager { panic(wire.Build( allProviders, diff --git a/conf/configuration.go b/conf/configuration.go index 83793bd43..57ee4e4bc 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -139,6 +139,8 @@ type configOptions struct { DevArtworkThrottleBacklogLimit int DevArtworkThrottleBacklogTimeout time.Duration DevArtworkThrottleBuffered bool + DevArtworkWorkerConcurrency int + DevArtworkExternalRPS int DevArtistInfoTimeToLive time.Duration DevAlbumInfoTimeToLive time.Duration DevExternalScanner bool @@ -900,6 +902,8 @@ func setViperDefaults() { viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit) viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout) viper.SetDefault("devartworkthrottlebuffered", true) + viper.SetDefault("devartworkworkerconcurrency", 2) + viper.SetDefault("devartworkexternalrps", 2) viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive) viper.SetDefault("devalbuminfotimetolive", consts.AlbumInfoTimeToLive) viper.SetDefault("devexternalscanner", true) diff --git a/core/artwork/image_store.go b/core/artwork/image_store.go index 4b4e482c9..53662f686 100644 --- a/core/artwork/image_store.go +++ b/core/artwork/image_store.go @@ -10,6 +10,8 @@ import ( "strings" "time" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" "github.com/zeebo/xxh3" ) @@ -31,6 +33,12 @@ func NewImageStore(rootDir string) *ImageStore { return &ImageStore{root: rootDir} } +// ProvideImageStore roots the store in its own subtree under the data folder, so +// Prune's recursive sweep never reaches the per-entity upload folders next to it. +func ProvideImageStore() *ImageStore { + return NewImageStore(filepath.Join(conf.Server.DataFolder.String(), consts.ArtworkFolder, "store")) +} + // extForMime is deliberately NOT mime.ExtensionsByType: extensions are baked into // content-addressed paths and re-derived on Open, so they must be stable across OSes. func extForMime(m string) string { diff --git a/core/artwork/prune.go b/core/artwork/prune.go index db1e4fdc1..a40194b8a 100644 --- a/core/artwork/prune.go +++ b/core/artwork/prune.go @@ -39,8 +39,8 @@ func Prune(ctx context.Context, ds model.DataStore, store *ImageStore) error { if _, ok := survivors[h]; ok { continue } - // A spared fresh file is at worst a stray a later sweep reclaims; full - // worker/prune mutual exclusion is Phase 2's concern. + // A spared fresh file is at worst a stray a later sweep reclaims; + // Worker.RunPrune serializes prune against in-flight acquisitions. if err := store.Remove(h, arts[h].Mime, cutoff); err != nil { log.Warn(ctx, "Prune: could not remove artwork file", "hash", h, err) } diff --git a/core/artwork/wire_providers.go b/core/artwork/wire_providers.go index 63231b54a..868ea8f56 100644 --- a/core/artwork/wire_providers.go +++ b/core/artwork/wire_providers.go @@ -8,4 +8,6 @@ var Set = wire.NewSet( NewArtwork, GetImageCache, NewCacheWarmer, + NewWorker, + ProvideImageStore, ) diff --git a/core/artwork/worker.go b/core/artwork/worker.go new file mode 100644 index 000000000..1ebe15527 --- /dev/null +++ b/core/artwork/worker.go @@ -0,0 +1,250 @@ +package artwork + +import ( + "context" + "errors" + "io" + "math" + "math/rand/v2" + "sync" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/ffmpeg" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "golang.org/x/time/rate" +) + +const ( + workerPollInterval = 5 * time.Second + backoffBase = 5 * time.Minute + backoffCap = 48 * time.Hour + breakerThreshold = 5 + breakerProbeAfter = time.Minute +) + +var errBreakerOpen = errors.New("artwork: external circuit breaker open") + +// Worker drains the artwork queue and runs each item through processItem. The +// external step is rate-limited and circuit-broken; prune is serialized against +// in-flight acquisitions via pruneMu (acquisitions RLock, prune Lock). +type Worker struct { + deps workerDeps + limiter *rate.Limiter + breaker *breaker + pruneMu sync.RWMutex + wake chan struct{} + runCtx context.Context + + mu sync.Mutex + inFlight map[string]struct{} +} + +func NewWorker(ds model.DataStore, store *ImageStore, prov external.Provider, ffmpeg ffmpeg.FFmpeg) *Worker { + rps := conf.Server.DevArtworkExternalRPS + limit := rate.Inf + if rps > 0 { + limit = rate.Limit(rps) + } + w := &Worker{ + deps: workerDeps{ds: ds, store: store, prov: prov, ffmpeg: ffmpeg}, + limiter: rate.NewLimiter(limit, max(1, rps)), + breaker: newBreaker(), + wake: make(chan struct{}, 1), + runCtx: context.Background(), + inFlight: map[string]struct{}{}, + } + w.deps.extGate = w.gate + return w +} + +// Run blocks draining the queue until ctx is cancelled. It exits cleanly with no +// leaked goroutines: each drain waits for its batch before the loop can return. +func (w *Worker) Run(ctx context.Context) error { + w.runCtx = ctx + concurrency := max(1, conf.Server.DevArtworkWorkerConcurrency) + ticker := time.NewTicker(workerPollInterval) + defer ticker.Stop() + for { + n, err := w.drain(ctx, concurrency) + if err != nil && ctx.Err() == nil { + log.Warn(ctx, "artwork: worker drain failed", err) + } + if ctx.Err() != nil { + return nil + } + if n > 0 { + continue // keep draining while the queue has ready work + } + select { + case <-ctx.Done(): + return nil + case <-ticker.C: + case <-w.wake: + } + } +} + +// Bump enqueues an item at the highest priority and wakes the drain loop. It is +// non-blocking: a wake already pending is enough. +func (w *Worker) Bump(kind, id string) { + item := model.ArtworkQueueItem{ + ItemKind: kind, + ItemID: id, + ImageType: model.ImageTypePrimary, + Priority: model.ArtworkPriorityBump, + } + if err := w.deps.ds.ArtworkQueue(context.Background()).Enqueue(item); err != nil { + log.Warn("artwork: could not bump queue item", "kind", kind, "id", id, err) + return + } + select { + case w.wake <- struct{}{}: + default: + } +} + +// RunPrune runs Prune under the worker's write lock, so no acquisition can place +// a file while orphans are being reclaimed. This is the only sanctioned prune path. +func (w *Worker) RunPrune(ctx context.Context) error { + w.pruneMu.Lock() + defer w.pruneMu.Unlock() + return Prune(ctx, w.deps.ds, w.deps.store) +} + +func (w *Worker) drain(ctx context.Context, concurrency int) (int, error) { + batch, err := w.deps.ds.ArtworkQueue(ctx).DequeueBatch(2 * concurrency) + if err != nil { + return 0, err + } + items := w.claim(batch) + if len(items) == 0 { + return 0, nil + } + sem := make(chan struct{}, concurrency) + var wg sync.WaitGroup + for _, item := range items { + sem <- struct{}{} + wg.Add(1) + go func(it model.ArtworkQueueItem) { + defer wg.Done() + defer func() { <-sem }() + defer w.release(it) + w.process(ctx, it) + }(item) + } + wg.Wait() + return len(items), nil +} + +func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) { + if item.ImageType == "" { + item.ImageType = model.ImageTypePrimary + } + w.pruneMu.RLock() + out := processItem(ctx, &w.deps, item) + w.pruneMu.RUnlock() + + queue := w.deps.ds.ArtworkQueue(ctx) + switch out { + case outcomeFound, outcomeAbsent: + if err := queue.Delete(item.ItemKind, item.ItemID, item.ImageType); err != nil { + log.Warn(ctx, "artwork: could not delete processed queue item", "kind", item.ItemKind, "id", item.ItemID, err) + } + case outcomeFailed: + retryAt := time.Now().Add(backoff(item.Attempts)) + if err := queue.MarkFailed(item.ItemKind, item.ItemID, item.ImageType, retryAt); err != nil { + log.Warn(ctx, "artwork: could not reschedule failed queue item", "kind", item.ItemKind, "id", item.ItemID, err) + } + } +} + +// claim reserves items not already in flight, so a wake-triggered re-drain never +// double-processes an item still running from a previous cycle. +func (w *Worker) claim(batch []model.ArtworkQueueItem) []model.ArtworkQueueItem { + w.mu.Lock() + defer w.mu.Unlock() + var out []model.ArtworkQueueItem + for _, it := range batch { + k := queueKey(it) + if _, busy := w.inFlight[k]; busy { + continue + } + w.inFlight[k] = struct{}{} + out = append(out, it) + } + return out +} + +func (w *Worker) release(it model.ArtworkQueueItem) { + w.mu.Lock() + delete(w.inFlight, queueKey(it)) + w.mu.Unlock() +} + +func queueKey(it model.ArtworkQueueItem) string { + return it.ItemKind + "|" + it.ItemID + "|" + it.ImageType +} + +// gate wraps the external step with the rate limiter and circuit breaker, matching +// extGateFunc so it can be injected via workerDeps.extGate. +func (w *Worker) gate(f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { + if !w.breaker.allow() { + return nil, "", errBreakerOpen + } + if err := w.limiter.Wait(w.runCtx); err != nil { + return nil, "", err + } + r, path, err := f() + w.breaker.record(err) + return r, path, err +} + +// backoffFor returns min(5m×4^n, 48h) scaled by (1+jitter), with jitter in [-0.2, 0.2]. +func backoffFor(attempts int, jitter float64) time.Duration { + d := math.Min(float64(backoffBase)*math.Pow(4, float64(attempts)), float64(backoffCap)) + return time.Duration(d * (1 + jitter)) +} + +func backoff(attempts int) time.Duration { + return backoffFor(attempts, rand.Float64()*0.4-0.2) //nolint:gosec // retry jitter, not security-sensitive +} + +// breaker opens after breakerThreshold consecutive external errors and admits a +// single probe once breakerProbeAfter has elapsed; a success re-closes it. +type breaker struct { + mu sync.Mutex + failures int + openedAt time.Time +} + +func newBreaker() *breaker { return &breaker{} } + +func (b *breaker) allow() bool { + b.mu.Lock() + defer b.mu.Unlock() + if b.failures < breakerThreshold { + return true + } + if time.Since(b.openedAt) >= breakerProbeAfter { + b.openedAt = time.Now() // start a fresh probe window so only one caller passes + return true + } + return false +} + +func (b *breaker) record(err error) { + b.mu.Lock() + defer b.mu.Unlock() + // A not-found is a definitive answer, not a fault; only real errors trip the breaker. + if err == nil || errors.Is(err, model.ErrNotFound) { + b.failures = 0 + return + } + b.failures++ + if b.failures == breakerThreshold { + b.openedAt = time.Now() + } +} diff --git a/core/artwork/worker_test.go b/core/artwork/worker_test.go new file mode 100644 index 000000000..d41249fa2 --- /dev/null +++ b/core/artwork/worker_test.go @@ -0,0 +1,188 @@ +package artwork + +import ( + "context" + "errors" + "io" + "net/url" + "os" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func findQueued(q *tests.MockArtworkQueueRepo, kind, id string) *model.ArtworkQueueItem { + for _, it := range q.Data { + if it.ItemKind == kind && it.ItemID == id { + return &it + } + } + return nil +} + +var _ = Describe("Worker", func() { + var ( + ctx context.Context + ds *tests.MockDataStore + folderRepo *fakeFolderRepo + libRepo *tests.MockLibraryRepo + ffm *tests.MockFFmpeg + prov *fakeExternalProvider + store *ImageStore + artRepo *tests.MockArtworkRepo + queueRepo *tests.MockArtworkQueueRepo + repoRoot string + w *Worker + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + ctx = context.Background() + var err error + repoRoot, err = os.Getwd() + Expect(err).ToNot(HaveOccurred()) + + folderRepo = &fakeFolderRepo{} + libRepo = &tests.MockLibraryRepo{} + libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) + ffm = tests.NewMockFFmpeg("") + prov = &fakeExternalProvider{} + artRepo = tests.CreateMockArtworkRepo() + queueRepo = tests.CreateMockArtworkQueueRepo() + ds = &tests.MockDataStore{ + MockedFolder: folderRepo, + MockedLibrary: libRepo, + MockedArtwork: artRepo, + MockedArtworkQueue: queueRepo, + } + ds.MockedAlbum = tests.CreateMockAlbumRepo() + store = NewImageStore(GinkgoT().TempDir()) + conf.Server.CoverArtPriority = "cover.jpg, embedded" + conf.Server.DevArtworkExternalRPS = 1000 // keep the limiter out of the way of behavior tests + w = NewWorker(ds, store, prov, ffm) + }) + + Describe("drain", func() { + It("processes a seeded queue item and removes it from the queue", func() { + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }} + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al1", Name: "Album", FolderIDs: []string{"f1"}}, + }) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ + ItemKind: "al", ItemID: "al1", Priority: model.ArtworkPriorityScan, + })).To(Succeed()) + + n, err := w.drain(ctx, 2) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(1)) + + ia, err := artRepo.GetItemArtwork("al", "al1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("folder")) + + count, err := queueRepo.Count() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(BeZero(), "a found item must be deleted from the queue") + }) + + It("reschedules a failed item via MarkFailed with a backed-off retry_at", func() { + conf.Server.CoverArtPriority = "external" + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al4", Name: "Album"}}) + prov.albumImage = func(context.Context, string) (*url.URL, error) { + return nil, errors.New("agent timed out") + } + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"})).To(Succeed()) + + n, err := w.drain(ctx, 2) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(1)) + + it := findQueued(queueRepo, "al", "al4") + Expect(it).ToNot(BeNil()) + Expect(it.Attempts).To(Equal(1)) + Expect(it.RetryAt).To(BeTemporally(">", time.Now())) + + _, err = artRepo.GetItemArtwork("al", "al4", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound), "a timeout must never settle on absent") + }) + + It("returns zero when the queue is empty", func() { + n, err := w.drain(ctx, 2) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(BeZero()) + }) + }) + + Describe("Bump", func() { + It("enqueues at Bump priority and wakes the loop", func() { + w.Bump("al", "al9") + it := findQueued(queueRepo, "al", "al9") + Expect(it).ToNot(BeNil()) + Expect(it.Priority).To(Equal(model.ArtworkPriorityBump)) + }) + }) + + Describe("gate/breaker", func() { + It("opens after 5 consecutive external errors and short-circuits the step", func() { + var calls int + failing := func() (io.ReadCloser, string, error) { + calls++ + return nil, "", errors.New("boom") + } + for range 5 { + _, _, err := w.gate(failing) + Expect(err).To(HaveOccurred()) + } + Expect(calls).To(Equal(5)) + + _, _, err := w.gate(failing) + Expect(err).To(HaveOccurred()) + Expect(calls).To(Equal(5), "an open breaker must not call the external step") + }) + + It("resets the failure count on a successful call", func() { + failing := func() (io.ReadCloser, string, error) { return nil, "", errors.New("boom") } + ok := func() (io.ReadCloser, string, error) { return io.NopCloser(nil), "p", nil } + for range 4 { + _, _, _ = w.gate(failing) + } + _, _, err := w.gate(ok) + Expect(err).ToNot(HaveOccurred()) + + var calls int + counting := func() (io.ReadCloser, string, error) { + calls++ + return nil, "", errors.New("boom") + } + for range 5 { + _, _, _ = w.gate(counting) + } + Expect(calls).To(Equal(5), "the breaker should have re-closed after the success") + }) + }) + + Describe("RunPrune", func() { + It("runs a prune under the worker mutex", func() { + Expect(w.RunPrune(ctx)).To(Succeed()) + }) + }) + + Describe("Run", func() { + It("exits cleanly when the context is cancelled", func() { + runCtx, cancel := context.WithCancel(ctx) + done := make(chan error, 1) + go func() { done <- w.Run(runCtx) }() + + cancel() + Eventually(done, time.Second).Should(Receive(BeNil())) + }) + }) +}) diff --git a/core/artwork/worker_timing_test.go b/core/artwork/worker_timing_test.go new file mode 100644 index 000000000..a721459c2 --- /dev/null +++ b/core/artwork/worker_timing_test.go @@ -0,0 +1,95 @@ +package artwork + +import ( + "context" + "errors" + "testing" + "testing/synctest" + "time" + + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/gomega" + "go.uber.org/goleak" +) + +func TestArtworkBackoffSchedule(t *testing.T) { + g := NewWithT(t) + for _, c := range []struct { + attempts int + want time.Duration + }{ + {0, 5 * time.Minute}, + {1, 20 * time.Minute}, + {2, 80 * time.Minute}, + {3, 320 * time.Minute}, + {4, 1280 * time.Minute}, + {5, 48 * time.Hour}, + {6, 48 * time.Hour}, + } { + g.Expect(backoffFor(c.attempts, 0)).To(Equal(c.want), "attempt %d", c.attempts) + } + + base := backoffFor(2, 0) + g.Expect(backoffFor(2, 0.2)).To(Equal(time.Duration(float64(base) * 1.2))) + g.Expect(backoffFor(2, -0.2)).To(Equal(time.Duration(float64(base) * 0.8))) + + lo := time.Duration(float64(320*time.Minute) * 0.8) + hi := time.Duration(float64(320*time.Minute) * 1.2) + for range 200 { + d := backoff(3) + g.Expect(d).To(BeNumerically(">=", lo)) + g.Expect(d).To(BeNumerically("<=", hi)) + } +} + +func TestArtworkBreakerHalfOpen(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + g := NewWithT(t) + b := newBreaker() + + for range breakerThreshold { + b.record(errors.New("boom")) + } + g.Expect(b.allow()).To(BeFalse(), "breaker opens after consecutive errors") + + time.Sleep(breakerProbeAfter - time.Nanosecond) + g.Expect(b.allow()).To(BeFalse(), "still open before the probe interval") + + time.Sleep(time.Nanosecond) + g.Expect(b.allow()).To(BeTrue(), "half-open: one probe is granted") + g.Expect(b.allow()).To(BeFalse(), "only a single probe per interval") + + b.record(errors.New("boom")) // probe fails -> stay open + time.Sleep(breakerProbeAfter) + g.Expect(b.allow()).To(BeTrue(), "another probe after the next interval") + + b.record(nil) // probe succeeds -> close + g.Expect(b.allow()).To(BeTrue(), "closed breaker admits freely") + g.Expect(b.allow()).To(BeTrue()) + }) +} + +func TestArtworkWorkerRunNoLeak(t *testing.T) { + ignore := goleak.IgnoreCurrent() + defer goleak.VerifyNone(t, ignore) + + t.Cleanup(configtest.SetupConfig()) + ds := &tests.MockDataStore{MockedArtworkQueue: tests.CreateMockArtworkQueueRepo()} + w := NewWorker(ds, NewImageStore(t.TempDir()), &fakeExternalProvider{}, tests.NewMockFFmpeg("")) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- w.Run(ctx) }() + + time.Sleep(20 * time.Millisecond) // let the loop settle on the idle select + cancel() + select { + case err := <-done: + if err != nil { + t.Fatalf("Run returned error on cancel: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("Run did not exit after context cancel") + } +} From 25a05fd017e0226a8560c92e687ff1c77c13e4ed Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 10:21:48 -0400 Subject: [PATCH 28/87] feat(artwork): enqueue artwork resolution from scan and CRUD paths --- core/artwork/prune.go | 9 +++++++ core/artwork/prune_test.go | 19 ++++++++++++++ model/artwork.go | 2 ++ persistence/artwork_repository.go | 24 +++++++++++++++++ persistence/artwork_repository_test.go | 36 ++++++++++++++++++++++++++ persistence/radio_repository.go | 12 ++++++++- persistence/radio_repository_test.go | 21 +++++++++++++++ scanner/phase_1_folders.go | 17 ++++++++++++ scanner/phase_4_playlists.go | 5 ++++ scanner/phase_4_playlists_test.go | 23 ++++++++++++++++ scanner/scanner_test.go | 23 ++++++++++++++++ tests/mock_artwork_repo.go | 21 +++++++++++++++ 12 files changed, 211 insertions(+), 1 deletion(-) diff --git a/core/artwork/prune.go b/core/artwork/prune.go index a40194b8a..5278beaa4 100644 --- a/core/artwork/prune.go +++ b/core/artwork/prune.go @@ -13,6 +13,15 @@ const pruneMinAge = time.Hour func Prune(ctx context.Context, ds model.DataStore, store *ImageStore) error { repo := ds.Artwork(ctx) + + purged, err := repo.PurgeDanglingItemArtwork() + if err != nil { + return err + } + if purged > 0 { + log.Info(ctx, "Prune: purged dangling item artwork state", "count", purged) + } + // One grace cutoff for both the DB orphan check and the file sweep: files younger // than the window may belong to acquisitions whose rows aren't committed yet. cutoff := time.Now().Add(-pruneMinAge) diff --git a/core/artwork/prune_test.go b/core/artwork/prune_test.go index 1f621363b..983e7c555 100644 --- a/core/artwork/prune_test.go +++ b/core/artwork/prune_test.go @@ -39,6 +39,25 @@ var _ = Describe("Prune", func() { awRepo.Data[h] = a } + It("purges dangling item_artwork state for gone entities, summed across kinds", func() { + Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "gone-album", ImageType: model.ImageTypePrimary})).To(Succeed()) + Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "gone-artist", ImageType: model.ImageTypePrimary})).To(Succeed()) + Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "live-artist", ImageType: model.ImageTypePrimary})).To(Succeed()) + awRepo.ExistingIDs = map[string]map[string]bool{ + "al": {}, + "ar": {"live-artist": true}, + } + + Expect(Prune(context.Background(), ds, store)).To(Succeed()) + + _, err := awRepo.GetItemArtwork("al", "gone-album", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + _, err = awRepo.GetItemArtwork("ar", "gone-artist", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + _, err = awRepo.GetItemArtwork("ar", "live-artist", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + }) + It("deletes orphan rows and their store files, keeps referenced ones", func() { data := []byte("orphan-bytes") h, _ := HashImage(bytes.NewReader(data)) diff --git a/model/artwork.go b/model/artwork.go index 7cb117764..73db00ca5 100644 --- a/model/artwork.go +++ b/model/artwork.go @@ -77,6 +77,8 @@ type ArtworkRepository interface { GetInfoForItems(kind string, ids []string) (map[string]ItemArtworkInfo, error) // GetAllMimes returns hash -> current mime for every stored artwork, for sweep retention checks. GetAllMimes() (map[string]string, error) + // PurgeDanglingItemArtwork removes state rows whose entity no longer exists. + PurgeDanglingItemArtwork() (int64, error) } type ArtworkQueueRepository interface { diff --git a/persistence/artwork_repository.go b/persistence/artwork_repository.go index 505e3839d..622f47bfe 100644 --- a/persistence/artwork_repository.go +++ b/persistence/artwork_repository.go @@ -114,6 +114,30 @@ func (r *artworkRepository) DeleteOrphans(createdBefore time.Time, hashes []stri return nil } +// danglingItemArtworkKinds maps item_kind prefixes to the table that owns the entity. +var danglingItemArtworkKinds = map[string]string{ + "al": "album", + "ar": "artist", + "pl": "playlist", + "ra": "radio", +} + +func (r *artworkRepository) PurgeDanglingItemArtwork() (int64, error) { + var total int64 + for kind, table := range danglingItemArtworkKinds { + del := Delete(itemArtworkTable).Where(And{ + Eq{"item_kind": kind}, + Expr("item_id NOT IN (SELECT id FROM " + table + ")"), + }) + c, err := r.items.executeSQL(del) + if err != nil { + return total, err + } + total += c + } + return total, nil +} + func (r *artworkRepository) GetItemArtwork(kind, id, imageType string) (*model.ItemArtwork, error) { sel := Select("*").From(itemArtworkTable). Where(Eq{"item_kind": kind, "item_id": id, "image_type": imageType}) diff --git a/persistence/artwork_repository_test.go b/persistence/artwork_repository_test.go index 82c2888c8..ae6752def 100644 --- a/persistence/artwork_repository_test.go +++ b/persistence/artwork_repository_test.go @@ -136,6 +136,42 @@ var _ = Describe("ArtworkRepository", func() { }) }) + Context("dangling state cleanup", func() { + It("purges item_artwork rows per kind whose entity no longer exists, summing counts", func() { + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: albumSgtPeppers.ID, ImageType: model.ImageTypePrimary, Hash: "keepAl"})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "no-such-album", ImageType: model.ImageTypePrimary, Hash: "danglingAl"})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: artistKraftwerk.ID, ImageType: model.ImageTypePrimary, Hash: "keepAr"})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "no-such-artist", ImageType: model.ImageTypePrimary, Hash: "danglingAr"})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "pl", ItemID: plsBest.ID, ImageType: model.ImageTypePrimary, Hash: "keepPl"})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "pl", ItemID: "no-such-playlist", ImageType: model.ImageTypePrimary, Hash: "danglingPl"})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ra", ItemID: radioWithHomePage.ID, ImageType: model.ImageTypePrimary, Hash: "keepRa"})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ra", ItemID: "no-such-radio", ImageType: model.ImageTypePrimary, Hash: "danglingRa"})).To(Succeed()) + + purged, err := repo.PurgeDanglingItemArtwork() + Expect(err).ToNot(HaveOccurred()) + Expect(purged).To(Equal(int64(4))) + + for _, kept := range []model.ItemArtwork{ + {ItemKind: "al", ItemID: albumSgtPeppers.ID}, + {ItemKind: "ar", ItemID: artistKraftwerk.ID}, + {ItemKind: "pl", ItemID: plsBest.ID}, + {ItemKind: "ra", ItemID: radioWithHomePage.ID}, + } { + _, err := repo.GetItemArtwork(kept.ItemKind, kept.ItemID, model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + } + for _, gone := range []model.ItemArtwork{ + {ItemKind: "al", ItemID: "no-such-album"}, + {ItemKind: "ar", ItemID: "no-such-artist"}, + {ItemKind: "pl", ItemID: "no-such-playlist"}, + {ItemKind: "ra", ItemID: "no-such-radio"}, + } { + _, err := repo.GetItemArtwork(gone.ItemKind, gone.ItemID, model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + } + }) + }) + Context("item state", func() { It("upserts and reads state, including per-item provenance", func() { ia := &model.ItemArtwork{ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, diff --git a/persistence/radio_repository.go b/persistence/radio_repository.go index a073643db..20f82ee36 100644 --- a/persistence/radio_repository.go +++ b/persistence/radio_repository.go @@ -7,6 +7,7 @@ import ( . "github.com/Masterminds/squirrel" "github.com/deluan/rest" + "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" "github.com/pocketbase/dbx" @@ -72,7 +73,16 @@ func (r *radioRepository) Put(radio *model.Radio, colsToUpdate ...string) error colsToUpdate = append(colsToUpdate, "UpdatedAt") } _, err := r.put(radio.ID, radio, colsToUpdate...) - return err + if err != nil { + return err + } + // Enqueue artwork resolution for the created/updated radio. Never fails the save. + item := model.ArtworkQueueItem{ItemKind: "ra", ItemID: radio.ID, ImageType: model.ImageTypePrimary, + Priority: model.ArtworkPriorityScan} + if err := NewArtworkQueueRepository(r.ctx, r.db).Enqueue(item); err != nil { + log.Warn(r.ctx, "could not enqueue radio artwork", "id", radio.ID, err) + } + return nil } func (r *radioRepository) Count(options ...rest.QueryOptions) (int64, error) { diff --git a/persistence/radio_repository_test.go b/persistence/radio_repository_test.go index 05628ca41..1d6cce4f8 100644 --- a/persistence/radio_repository_test.go +++ b/persistence/radio_repository_test.go @@ -107,6 +107,27 @@ var _ = Describe("RadioRepository", func() { Expect(err).To(BeNil()) Expect(all[2].StreamUrl).To(Equal("https://example.com:4533/app")) }) + + It("enqueues artwork resolution for the saved radio", func() { + err := repo.Put(&model.Radio{ + Name: "Artwork radio", + StreamUrl: "https://example.com:4533/artwork", + }) + Expect(err).To(BeNil()) + + all, err := repo.GetAll() + Expect(err).To(BeNil()) + created := all[len(all)-1] + + queueRepo := NewArtworkQueueRepository(context.Background(), GetDBXBuilder()) + queued, err := queueRepo.DequeueBatch(1000) + Expect(err).To(BeNil()) + Expect(queued).To(ContainElement(SatisfyAll( + HaveField("ItemKind", "ra"), + HaveField("ItemID", created.ID), + HaveField("Priority", model.ArtworkPriorityScan), + ))) + }) }) }) diff --git a/scanner/phase_1_folders.go b/scanner/phase_1_folders.go index 5e898590b..3a7265cc4 100644 --- a/scanner/phase_1_folders.go +++ b/scanner/phase_1_folders.go @@ -332,6 +332,8 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error) // Collect artwork IDs to pre-cache after the transaction commits var artworkIDs []model.ArtworkID + // Collect artwork queue items for changed albums/artists, enqueued in the same transaction + var queueItems []model.ArtworkQueueItem err := p.ds.WithTx(func(tx model.DataStore) error { // Instantiate all repositories just once per folder @@ -372,6 +374,10 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error) } if entry.artists[i].Name != consts.UnknownArtist && entry.artists[i].Name != consts.VariousArtists { artworkIDs = append(artworkIDs, entry.artists[i].CoverArtID()) + queueItems = append(queueItems, model.ArtworkQueueItem{ + ItemKind: "ar", ItemID: entry.artists[i].ID, ImageType: model.ImageTypePrimary, + Priority: model.ArtworkPriorityScan, + }) } } @@ -384,6 +390,10 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error) } if entry.albums[i].Name != consts.UnknownAlbum { artworkIDs = append(artworkIDs, entry.albums[i].CoverArtID()) + queueItems = append(queueItems, model.ArtworkQueueItem{ + ItemKind: "al", ItemID: entry.albums[i].ID, ImageType: model.ImageTypePrimary, + Priority: model.ArtworkPriorityScan, + }) } } @@ -415,6 +425,13 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error) return err } } + + // Enqueue artwork resolution for changed albums/artists. Never fails the scan. + if len(queueItems) > 0 { + if err := tx.ArtworkQueue(p.ctx).Enqueue(queueItems...); err != nil { + log.Warn(p.ctx, "Scanner: could not enqueue artwork resolution", "folder", entry.path, err) + } + } return nil }, "scanner: persist changes") if err != nil { diff --git a/scanner/phase_4_playlists.go b/scanner/phase_4_playlists.go index 8ba014235..15798b054 100644 --- a/scanner/phase_4_playlists.go +++ b/scanner/phase_4_playlists.go @@ -149,6 +149,11 @@ func (p *phasePlaylists) processPlaylistsInFolder(folder *model.Folder) (*model. log.Debug("Scanner: Imported playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "numTracks", len(pls.Tracks), "elapsed", time.Since(started)) } p.cw.PreCache(pls.CoverArtID()) + item := model.ArtworkQueueItem{ItemKind: "pl", ItemID: pls.ID, ImageType: model.ImageTypePrimary, + Priority: model.ArtworkPriorityScan} + if err := p.ds.ArtworkQueue(p.ctx).Enqueue(item); err != nil { + log.Warn(p.ctx, "Scanner: could not enqueue playlist artwork", "id", pls.ID, err) + } p.refreshed.Add(1) } return folder, nil diff --git a/scanner/phase_4_playlists_test.go b/scanner/phase_4_playlists_test.go index 49ffc7fb7..1adf34a63 100644 --- a/scanner/phase_4_playlists_test.go +++ b/scanner/phase_4_playlists_test.go @@ -193,6 +193,29 @@ var _ = Describe("phasePlaylists", func() { Expect(phase.refreshed.Load()).To(Equal(uint32(2))) }) + It("enqueues artwork resolution for the imported playlist", func() { + libPath := GinkgoT().TempDir() + folder := &model.Folder{LibraryPath: libPath, Path: "path/to", Name: "folder"} + _ = os.MkdirAll(folder.AbsolutePath(), 0755) + + file1 := filepath.Join(folder.AbsolutePath(), "playlist1.m3u") + _ = os.WriteFile(file1, []byte{}, 0600) + + pls.On("ImportFromFolder", mock.Anything, folder, "playlist1.m3u"). + Return(&model.Playlist{ID: "pl1"}, nil) + + _, err := phase.processPlaylistsInFolder(folder) + Expect(err).ToNot(HaveOccurred()) + + queued, err := ds.ArtworkQueue(ctx).DequeueBatch(10) + Expect(err).ToNot(HaveOccurred()) + Expect(queued).To(ContainElement(SatisfyAll( + HaveField("ItemKind", "pl"), + HaveField("ItemID", "pl1"), + HaveField("Priority", model.ArtworkPriorityScan), + ))) + }) + It("reports an error if there is an error reading files", func() { tests.SkipOnWindows("relies on Unix /etc filesystem") progress := make(chan *ProgressInfo) diff --git a/scanner/scanner_test.go b/scanner/scanner_test.go index cc3720717..0a7fb07b2 100644 --- a/scanner/scanner_test.go +++ b/scanner/scanner_test.go @@ -152,6 +152,29 @@ var _ = Describe("Scanner", Ordered, func() { HaveField("SongCount", Equal(4)), )) }) + It("should enqueue artwork resolution for the scanned albums and artists", func() { + Expect(runScanner(ctx, true)).To(Succeed()) + + albums, _ := ds.Album(ctx).GetAll() + artists, _ := ds.Artist(ctx).GetAll(model.QueryOptions{Filters: squirrel.NotEq{"name": consts.UnknownArtist}}) + queued, err := ds.ArtworkQueue(ctx).DequeueBatch(1000) + Expect(err).ToNot(HaveOccurred()) + + for _, al := range albums { + Expect(queued).To(ContainElement(SatisfyAll( + HaveField("ItemKind", "al"), + HaveField("ItemID", al.ID), + HaveField("Priority", model.ArtworkPriorityScan), + ))) + } + for _, ar := range artists { + Expect(queued).To(ContainElement(SatisfyAll( + HaveField("ItemKind", "ar"), + HaveField("ItemID", ar.ID), + HaveField("Priority", model.ArtworkPriorityScan), + ))) + } + }) }) When("a file was changed", func() { It("should update the media_file", func() { diff --git a/tests/mock_artwork_repo.go b/tests/mock_artwork_repo.go index 13ad4d5e1..db73af84d 100644 --- a/tests/mock_artwork_repo.go +++ b/tests/mock_artwork_repo.go @@ -12,6 +12,8 @@ type MockArtworkRepo struct { ItemData map[string]model.ItemArtwork // keyed by iaKey(kind, id, imageType) OrphanHashes []string Err error + // ExistingIDs, keyed by item_kind, backs PurgeDanglingItemArtwork; nil map keeps everything. + ExistingIDs map[string]map[string]bool } func CreateMockArtworkRepo() *MockArtworkRepo { @@ -68,6 +70,25 @@ func (m *MockArtworkRepo) GetAllMimes() (map[string]string, error) { return mimes, nil } +func (m *MockArtworkRepo) PurgeDanglingItemArtwork() (int64, error) { + if m.Err != nil { + return 0, m.Err + } + var purged int64 + for k, ia := range m.ItemData { + // A nil per-kind map means that kind isn't tracked by the test, so keep it. + existing := m.ExistingIDs[ia.ItemKind] + if existing == nil { + continue + } + if !existing[ia.ItemID] { + delete(m.ItemData, k) + purged++ + } + } + return purged, nil +} + func (m *MockArtworkRepo) DeleteOrphans(createdBefore time.Time, hashes []string) error { if m.Err != nil { return m.Err From ad38cd1d584e83e347372cbf3a98ae3068b12a65 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 10:40:34 -0400 Subject: [PATCH 29/87] feat(artwork): artwork backfill, fingerprint re-resolution and scheduled jobs --- cmd/root.go | 54 +++++++++- consts/consts.go | 4 + core/artwork/housekeeping.go | 120 +++++++++++++++++++++ core/artwork/housekeeping_test.go | 174 ++++++++++++++++++++++++++++++ 4 files changed, 349 insertions(+), 3 deletions(-) create mode 100644 core/artwork/housekeeping.go create mode 100644 core/artwork/housekeeping_test.go diff --git a/cmd/root.go b/cmd/root.go index 2b64ab850..4df5c1cb8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -11,6 +11,7 @@ import ( "github.com/go-chi/chi/v5/middleware" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -88,7 +89,9 @@ func runNavidrome(ctx context.Context) { g.Go(startInsightsCollector(ctx)) g.Go(scheduleDBAnalyzer(ctx)) g.Go(startPluginManager(ctx)) - g.Go(startArtworkWorker(ctx)) + artworkWorker := CreateArtworkWorker() + g.Go(startArtworkWorker(ctx, artworkWorker)) + g.Go(scheduleArtworkHousekeeping(ctx, artworkWorker)) g.Go(runInitialScan(ctx)) if conf.Server.Scanner.Enabled { g.Go(startScanWatcher(ctx)) @@ -347,10 +350,55 @@ func startPlaybackServer(ctx context.Context) func() error { // startArtworkWorker starts the background artwork acquisition worker. It always // runs; the queue is simply empty until something enqueues work into it. -func startArtworkWorker(ctx context.Context) func() error { +func startArtworkWorker(ctx context.Context, worker *artwork.Worker) func() error { return func() error { log.Info(ctx, "Starting artwork worker") - return CreateArtworkWorker().Run(ctx) + return worker.Run(ctx) + } +} + +// scheduleArtworkHousekeeping runs the startup fingerprint backfill and registers the +// recurring stale-absent recheck and prune jobs. Scan-triggered prune lands in a later phase. +func scheduleArtworkHousekeeping(ctx context.Context, worker *artwork.Worker) func() error { + return func() error { + ds := CreateDataStore() + schedulerInstance := scheduler.GetInstance() + + if _, err := schedulerInstance.Add(consts.ArtworkStaleAbsentRecheckSchedule, func() { + if err := artwork.EnqueueStaleAbsentAll(ctx, ds); err != nil { + log.Error(ctx, "Error enqueueing stale artwork rechecks", err) + } + }); err != nil { + log.Error(ctx, "Error scheduling artwork stale-absent recheck", err) + } + + if _, err := schedulerInstance.Add(consts.ArtworkPruneSchedule, func() { + if err := worker.RunPrune(ctx); err != nil { + log.Error(ctx, "Error running artwork prune", err) + } + }); err != nil { + log.Error(ctx, "Error scheduling artwork prune", err) + } + + backfilled, err := artwork.Backfill(ctx, ds) + if err != nil { + log.Error(ctx, "Error running artwork backfill", err) + return nil + } + if !backfilled { + return nil + } + log.Info(ctx, "Artwork backfill enqueued, scheduling a follow-up prune") + timer := time.NewTimer(consts.ArtworkPostBackfillPruneDelay) + defer timer.Stop() + select { + case <-timer.C: + if err := worker.RunPrune(ctx); err != nil { + log.Error(ctx, "Error running post-backfill artwork prune", err) + } + case <-ctx.Done(): + } + return nil } } diff --git a/consts/consts.go b/consts/consts.go index f453ac125..0a244f55f 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -35,6 +35,10 @@ const ( DBAnalyzeCheckSchedule = "@every 30m" DBAnalyzeMaxAge = 24 * time.Hour + ArtworkStaleAbsentRecheckSchedule = "@every 1h" + ArtworkPruneSchedule = "@daily" + ArtworkPostBackfillPruneDelay = 10 * time.Minute + // DefaultEncryptionKey This is the encryption key used if none is specified in the `PasswordEncryptionKey` option // Never ever change this! Or it will break all Navidrome installations that don't set the config option DefaultEncryptionKey = "just for obfuscation" diff --git a/core/artwork/housekeeping.go b/core/artwork/housekeeping.go new file mode 100644 index 000000000..6f1fa20a3 --- /dev/null +++ b/core/artwork/housekeeping.go @@ -0,0 +1,120 @@ +package artwork + +import ( + "context" + "crypto/md5" + "encoding/hex" + "fmt" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +// FingerprintPropertyKey is the model.PropertyRepository key Backfill compares against +// to detect artwork-affecting config changes across restarts. +const FingerprintPropertyKey = "artwork.fingerprint" + +// staleAbsentAge is how old an absent resolution must be before the recheck job retries it. +const staleAbsentAge = 24 * time.Hour + +// staleAbsentKinds are the item kinds eligible for the periodic stale-absent recheck. +var staleAbsentKinds = []string{"ar", "al", "pl", "ra"} + +// Fingerprint summarizes the config knobs that affect artwork resolution outcomes; a +// change means previously resolved (or absent) state may no longer be correct. +func Fingerprint() string { + raw := fmt.Sprintf("%s|%s|%s|%t|%s", + conf.Server.CoverArtPriority, conf.Server.ArtistArtPriority, + conf.Server.Agents, conf.Server.EnableExternalServices, consts.Version) + sum := md5.Sum([]byte(raw)) //nolint:gosec // fingerprint, not security-sensitive + return hex.EncodeToString(sum[:]) +} + +// Backfill enqueues artwork resolution for every entity when the config fingerprint changed +// (or was never stored), artists first so those pages resolve before the larger backlog. +func Backfill(ctx context.Context, ds model.DataStore) (bool, error) { + current := Fingerprint() + props := ds.Property(ctx) + stored, err := props.DefaultGet(FingerprintPropertyKey, "") + if err != nil { + return false, err + } + if stored == current { + return false, nil + } + + artists, err := ds.Artist(ctx).GetAll() + if err != nil { + return false, err + } + if err := enqueueBackfillKind(ctx, ds, "ar", idsOf(artists, func(a model.Artist) string { return a.ID })); err != nil { + return false, err + } + + albums, err := ds.Album(ctx).GetAll() + if err != nil { + return false, err + } + if err := enqueueBackfillKind(ctx, ds, "al", idsOf(albums, func(a model.Album) string { return a.ID })); err != nil { + return false, err + } + + playlists, err := ds.Playlist(ctx).GetAll() + if err != nil { + return false, err + } + if err := enqueueBackfillKind(ctx, ds, "pl", idsOf(playlists, func(p model.Playlist) string { return p.ID })); err != nil { + return false, err + } + + radios, err := ds.Radio(ctx).GetAll() + if err != nil { + return false, err + } + if err := enqueueBackfillKind(ctx, ds, "ra", idsOf(radios, func(r model.Radio) string { return r.ID })); err != nil { + return false, err + } + + if err := props.Put(FingerprintPropertyKey, current); err != nil { + return false, err + } + log.Info(ctx, "Artwork: config fingerprint changed, backfill enqueued") + return true, nil +} + +func enqueueBackfillKind(ctx context.Context, ds model.DataStore, kind string, ids []string) error { + if len(ids) == 0 { + return nil + } + items := make([]model.ArtworkQueueItem, len(ids)) + for i, id := range ids { + items[i] = model.ArtworkQueueItem{ + ItemKind: kind, ItemID: id, ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBackfill, + } + } + return ds.ArtworkQueue(ctx).Enqueue(items...) +} + +func idsOf[T any](items []T, id func(T) string) []string { + ids := make([]string, len(items)) + for i, it := range items { + ids[i] = id(it) + } + return ids +} + +// EnqueueStaleAbsentAll requeues absent-state entries older than staleAbsentAge, across +// every artwork-bearing kind, for the periodic recheck job. +func EnqueueStaleAbsentAll(ctx context.Context, ds model.DataStore) error { + cutoff := time.Now().Add(-staleAbsentAge) + queue := ds.ArtworkQueue(ctx) + for _, kind := range staleAbsentKinds { + if _, err := queue.EnqueueStaleAbsent(kind, cutoff); err != nil { + return err + } + } + return nil +} diff --git a/core/artwork/housekeeping_test.go b/core/artwork/housekeeping_test.go new file mode 100644 index 000000000..8272514b0 --- /dev/null +++ b/core/artwork/housekeeping_test.go @@ -0,0 +1,174 @@ +package artwork + +import ( + "context" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// orderTrackingQueueRepo records the item kind of each Enqueue call, so tests can +// assert phase ordering (artists-first) that same-priority timestamps can't guarantee. +type orderTrackingQueueRepo struct { + *tests.MockArtworkQueueRepo + callKinds []string +} + +func (o *orderTrackingQueueRepo) Enqueue(items ...model.ArtworkQueueItem) error { + if len(items) > 0 { + o.callKinds = append(o.callKinds, items[0].ItemKind) + } + return o.MockArtworkQueueRepo.Enqueue(items...) +} + +var _ = Describe("Housekeeping", func() { + var ( + ctx context.Context + ds *tests.MockDataStore + queueRepo *orderTrackingQueueRepo + propRepo *tests.MockedPropertyRepo + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + ctx = context.Background() + conf.Server.CoverArtPriority = "embedded, folder" + conf.Server.ArtistArtPriority = "artist.jpg" + conf.Server.Agents = "spotify" + conf.Server.EnableExternalServices = true + + queueRepo = &orderTrackingQueueRepo{MockArtworkQueueRepo: tests.CreateMockArtworkQueueRepo()} + propRepo = &tests.MockedPropertyRepo{} + ds = &tests.MockDataStore{MockedArtworkQueue: queueRepo, MockedProperty: propRepo} + }) + + seedEntities := func() { + artistRepo := tests.CreateMockArtistRepo() + artistRepo.SetData(model.Artists{{ID: "ar1"}, {ID: "ar2"}}) + ds.MockedArtist = artistRepo + + albumRepo := tests.CreateMockAlbumRepo() + albumRepo.SetData(model.Albums{{ID: "al1"}}) + ds.MockedAlbum = albumRepo + + playlistRepo := tests.CreateMockPlaylistRepo() + playlistRepo.SetData(model.Playlists{{ID: "pl1"}}) + ds.MockedPlaylist = playlistRepo + + radioRepo := tests.CreateMockedRadioRepo() + radioRepo.All = model.Radios{{ID: "ra1"}} + ds.MockedRadio = radioRepo + } + + Describe("Fingerprint", func() { + It("changes when a fingerprint-affecting config value changes", func() { + f1 := Fingerprint() + conf.Server.CoverArtPriority = "folder, embedded" + f2 := Fingerprint() + Expect(f1).NotTo(Equal(f2)) + }) + }) + + Describe("Backfill", func() { + It("enqueues nothing and returns false when the stored fingerprint matches", func() { + seedEntities() + Expect(propRepo.Put(FingerprintPropertyKey, Fingerprint())).To(Succeed()) + + did, err := Backfill(ctx, ds) + Expect(err).ToNot(HaveOccurred()) + Expect(did).To(BeFalse()) + + count, err := queueRepo.Count() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(BeZero()) + }) + + It("runs the backfill when no fingerprint was ever stored", func() { + seedEntities() + + did, err := Backfill(ctx, ds) + Expect(err).ToNot(HaveOccurred()) + Expect(did).To(BeTrue()) + + count, err := queueRepo.Count() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(int64(5))) // 2 artists + 1 album + 1 playlist + 1 radio + + stored, err := propRepo.Get(FingerprintPropertyKey) + Expect(err).ToNot(HaveOccurred()) + Expect(stored).To(Equal(Fingerprint())) + }) + + It("enqueues artists before albums/playlists/radios, all at Backfill priority", func() { + seedEntities() + Expect(propRepo.Put(FingerprintPropertyKey, "stale-fingerprint")).To(Succeed()) + + did, err := Backfill(ctx, ds) + Expect(err).ToNot(HaveOccurred()) + Expect(did).To(BeTrue()) + + Expect(queueRepo.callKinds).ToNot(BeEmpty()) + artistCallIdx := -1 + for i, k := range queueRepo.callKinds { + if k == "ar" { + artistCallIdx = i + break + } + } + Expect(artistCallIdx).To(Equal(0), "artists must be the first Enqueue call") + for i, k := range queueRepo.callKinds { + if k != "ar" { + Expect(i).To(BeNumerically(">", artistCallIdx)) + } + } + + for _, it := range queueRepo.Data { + Expect(it.Priority).To(Equal(model.ArtworkPriorityBackfill)) + Expect(it.ItemKind).To(BeElementOf("ar", "al", "pl", "ra")) + } + }) + }) + + Describe("EnqueueStaleAbsentAll", func() { + var artRepo *tests.MockArtworkRepo + + BeforeEach(func() { + artRepo = tests.CreateMockArtworkRepo() + ds.MockedArtwork = artRepo + queueRepo.ItemArtworkSource = artRepo + }) + + It("enqueues only absent entries older than the recheck window, across all kinds", func() { + old := time.Now().Add(-48 * time.Hour) + recent := time.Now().Add(-time.Hour) + + artRepo.ItemData["ar-stale"] = model.ItemArtwork{ItemKind: "ar", ItemID: "ar1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old} + artRepo.ItemData["al-stale"] = model.ItemArtwork{ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old} + artRepo.ItemData["pl-stale"] = model.ItemArtwork{ItemKind: "pl", ItemID: "pl1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old} + artRepo.ItemData["ra-stale"] = model.ItemArtwork{ItemKind: "ra", ItemID: "ra1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old} + // Not stale: too recent. + artRepo.ItemData["ar-recent"] = model.ItemArtwork{ItemKind: "ar", ItemID: "ar2", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: recent} + // Not absent: has a resolved hash. + artRepo.ItemData["al-resolved"] = model.ItemArtwork{ItemKind: "al", ItemID: "al2", ImageType: model.ImageTypePrimary, Hash: "somehash", AttemptedAt: old} + + err := EnqueueStaleAbsentAll(ctx, ds) + Expect(err).ToNot(HaveOccurred()) + + Expect(queueRepo.Data).To(HaveLen(4)) + for _, it := range queueRepo.Data { + Expect(it.Priority).To(Equal(model.ArtworkPriorityRecheck)) + } + Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ar", "ar1")).ToNot(BeNil()) + Expect(findQueued(queueRepo.MockArtworkQueueRepo, "al", "al1")).ToNot(BeNil()) + Expect(findQueued(queueRepo.MockArtworkQueueRepo, "pl", "pl1")).ToNot(BeNil()) + Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ra", "ra1")).ToNot(BeNil()) + Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ar", "ar2")).To(BeNil()) + Expect(findQueued(queueRepo.MockArtworkQueueRepo, "al", "al2")).To(BeNil()) + }) + }) +}) From 1ed8ebf9b0ea47643cd87f637fec38057ab3bfd3 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 11:16:46 -0400 Subject: [PATCH 30/87] test(artwork): leak/soak coverage and deferred assertions --- core/artwork/artwork_suite_test.go | 17 +-- core/artwork/image_store_test.go | 13 ++- core/artwork/prune_test.go | 49 +++++++++ core/artwork/worker_soak_test.go | 159 +++++++++++++++++++++++++++++ 4 files changed, 230 insertions(+), 8 deletions(-) create mode 100644 core/artwork/worker_soak_test.go diff --git a/core/artwork/artwork_suite_test.go b/core/artwork/artwork_suite_test.go index 1fd04627a..b721200e1 100644 --- a/core/artwork/artwork_suite_test.go +++ b/core/artwork/artwork_suite_test.go @@ -19,12 +19,17 @@ import ( ) func TestArtwork(t *testing.T) { - // Only run goleak checks when the GOLEAK env var is set - if os.Getenv("GOLEAK") != "" { - defer goleak.VerifyNone(t, - goleak.IgnoreTopFunction("github.com/onsi/ginkgo/v2/internal/interrupt_handler.(*InterruptHandler).registerForInterrupts.func2"), - ) - } + // Runs unconditionally: the two leaks below are pre-existing and out of this + // package's control, so they're ignored by exact top-function instead. + defer goleak.VerifyNone(t, + goleak.IgnoreTopFunction("github.com/onsi/ginkgo/v2/internal/interrupt_handler.(*InterruptHandler).registerForInterrupts.func2"), + // notify's own init() starts this dispatcher the moment it's imported + // (via core/storage/local or plugins); it never exits. + goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*recursiveTree).dispatch"), + // The old cache_warmer.go starts a goroutine per NewCacheWarmer call with + // no shutdown path (dark-launch target for Phase 2, not touched here). + goleak.IgnoreTopFunction("github.com/navidrome/navidrome/core/artwork.(*cacheWarmer).waitSignal"), + ) tests.Init(t, false) log.SetLevel(log.LevelFatal) diff --git a/core/artwork/image_store_test.go b/core/artwork/image_store_test.go index 214cab3ad..6537ca906 100644 --- a/core/artwork/image_store_test.go +++ b/core/artwork/image_store_test.go @@ -44,11 +44,20 @@ var _ = Describe("ImageStore", func() { Expect(got).To(Equal(data)) }) - It("is idempotent on duplicate writes", func() { + It("is idempotent on duplicate writes and preserves the original content", func() { data := []byte("dup") h, _ := HashImage(bytes.NewReader(data)) Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed()) - Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed()) + // A duplicate write only touches mtime; passing different bytes under the same + // hash proves the second reader is never consumed to overwrite the file. + Expect(store.Write(h, "image/png", bytes.NewReader([]byte("not-dup")))).To(Succeed()) + + rc, err := store.Open(h, "image/png") + Expect(err).ToNot(HaveOccurred()) + defer rc.Close() + got, err := io.ReadAll(rc) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(Equal(data)) }) It("refreshes the mtime on a duplicate write", func() { diff --git a/core/artwork/prune_test.go b/core/artwork/prune_test.go index 983e7c555..3de46942a 100644 --- a/core/artwork/prune_test.go +++ b/core/artwork/prune_test.go @@ -5,6 +5,7 @@ import ( "context" "errors" "os" + "path/filepath" "time" "github.com/navidrome/navidrome/model" @@ -171,6 +172,54 @@ var _ = Describe("Prune", func() { rc.Close() }) + It("warns and continues past a store.Remove failure instead of aborting the loop", func() { + tests.SkipOnWindows("uses Unix file permission bits") + old := time.Now().Add(-2 * time.Hour) + + blocked := []byte("blocked-bytes") + hb, _ := HashImage(bytes.NewReader(blocked)) + Expect(store.Write(hb, "image/jpeg", bytes.NewReader(blocked))).To(Succeed()) + Expect(os.Chtimes(store.path(hb, "image/jpeg"), old, old)).To(Succeed()) + Expect(awRepo.PutImage(&model.Artwork{Hash: hb, Mime: "image/jpeg"})).To(Succeed()) + ageArtwork(hb, old) + + good := []byte("good-bytes") + hg, _ := HashImage(bytes.NewReader(good)) + Expect(store.Write(hg, "image/jpeg", bytes.NewReader(good))).To(Succeed()) + Expect(os.Chtimes(store.path(hg, "image/jpeg"), old, old)).To(Succeed()) + Expect(awRepo.PutImage(&model.Artwork{Hash: hg, Mime: "image/jpeg"})).To(Succeed()) + ageArtwork(hg, old) + + // A read-only shard directory makes os.Remove fail (EACCES) for hb's file only. + shardDir := filepath.Dir(store.path(hb, "image/jpeg")) + Expect(os.Chmod(shardDir, 0500)).To(Succeed()) + DeferCleanup(func() { _ = os.Chmod(shardDir, 0755) }) + + // hb (blocked) is processed first: if store.Remove's failure aborted the loop + // instead of warning and continuing, hg would never be reached. + awRepo.OrphanHashes = []string{hb, hg} + + // Prune still errors: Sweep independently revisits hb's leftover file and, + // unlike the loop below, has no warn-and-continue fallback of its own. + err := Prune(context.Background(), ds, store) + Expect(err).To(HaveOccurred()) + + // hg: reached and fully pruned despite being queued after the failing hb - + // proof the loop didn't return/break on the first Remove error. + _, err = awRepo.GetImage(hg) + Expect(err).To(MatchError(model.ErrNotFound)) + _, err = store.Open(hg, "image/jpeg") + Expect(os.IsNotExist(err)).To(BeTrue()) + + // hb: row still purged (DeleteOrphans doesn't depend on file removal), but the + // file itself survives since store.Remove failed and only warned. + _, err = awRepo.GetImage(hb) + Expect(err).To(MatchError(model.ErrNotFound)) + rc, err := store.Open(hb, "image/jpeg") + Expect(err).ToNot(HaveOccurred()) + rc.Close() + }) + It("never sweeps files on a transient DB error", func() { ds.MockedArtwork = &flakyGetArtworkRepo{MockArtworkRepo: tests.CreateMockArtworkRepo()} diff --git a/core/artwork/worker_soak_test.go b/core/artwork/worker_soak_test.go new file mode 100644 index 000000000..f6ecef422 --- /dev/null +++ b/core/artwork/worker_soak_test.go @@ -0,0 +1,159 @@ +package artwork + +import ( + "context" + "io" + "os" + "runtime" + "testing" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" +) + +// soakCycles is deliberately >2000: this is a leak regression guard, not a +// performance benchmark, so it favors a stable signal over raw speed. +const soakCycles = 2200 + +// TestWorkerSoak drives processItem across a mix of sources (folder, embedded +// extraction, dangling refs) for many cycles, reading each acquired image back +// through ImageStore.Open, and asserts goroutines/heap plateau instead of +// growing unbounded. Skipped under -short. +func TestWorkerSoak(t *testing.T) { + if testing.Short() { + t.Skip("skipping soak test in short mode") + } + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + defer configtest.SetupConfig()() + + repoRoot, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + + libRepo := &tests.MockLibraryRepo{} + libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) + folderRepo := &fakeFolderRepo{result: []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }}} + ffm := tests.NewMockFFmpeg("") + prov := &fakeExternalProvider{} + artRepo := tests.CreateMockArtworkRepo() + albumRepo := tests.CreateMockAlbumRepo() + albumRepo.SetData(model.Albums{ + {ID: "al-folder", Name: "Folder Album", FolderIDs: []string{"f1"}}, + {ID: "al-embed", Name: "Embedded Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}, + }) + ds := &tests.MockDataStore{ + MockedFolder: folderRepo, + MockedLibrary: libRepo, + MockedArtwork: artRepo, + MockedAlbum: albumRepo, + } + store := NewImageStore(t.TempDir()) + deps := &workerDeps{ds: ds, store: store, prov: prov, ffmpeg: ffm} + conf.Server.CoverArtPriority = "cover.jpg, embedded" + + // Dangling refs (al/ra ids the repos don't know about) mirror an entity + // deleted after being enqueued; ds.Radio auto-provisions an empty mock repo. + items := []model.ArtworkQueueItem{ + {ItemKind: "al", ItemID: "al-folder"}, + {ItemKind: "al", ItemID: "al-embed"}, + {ItemKind: "al", ItemID: "al-does-not-exist"}, + {ItemKind: "ra", ItemID: "ra-does-not-exist"}, + } + + fdCount := func() int { + if runtime.GOOS != "linux" { + return -1 + } + entries, err := os.ReadDir("/proc/self/fd") + if err != nil { + return -1 + } + return len(entries) + } + + settleGoroutines := func() int { + // Background goroutines (GC workers, etc.) can take a moment to wind down; + // poll for two consecutive equal samples instead of trusting a single one. + prev := -1 + for range 100 { + runtime.GC() + n := runtime.NumGoroutine() + if n == prev { + return n + } + prev = n + time.Sleep(10 * time.Millisecond) + } + return prev + } + + baselineGoroutines := settleGoroutines() + baselineFDs := fdCount() + + var heapAt10Pct uint64 + start := time.Now() + for i := range soakCycles { + it := items[i%len(items)] + out := processItem(context.Background(), deps, it) + + // "Serve-adjacent" read-back: exercise the Phase 2 surfaces a caller would + // use after acquisition, not the old serving pipeline. + if out == outcomeFound { + ia, err := artRepo.GetItemArtwork(it.ItemKind, it.ItemID, model.ImageTypePrimary) + if err != nil { + t.Fatalf("cycle %d: GetItemArtwork: %v", i, err) + } + art, err := artRepo.GetImage(ia.Hash) + if err != nil { + t.Fatalf("cycle %d: GetImage: %v", i, err) + } + rc, err := store.Open(ia.Hash, art.Mime) + switch { + case err == nil: + _, _ = io.Copy(io.Discard, rc) + rc.Close() + case os.IsNotExist(err): + // Folder-backed art has no store file; that's expected. + default: + t.Fatalf("cycle %d: store.Open: %v", i, err) + } + } + + if i == soakCycles/10 { + runtime.GC() + var ms runtime.MemStats + runtime.ReadMemStats(&ms) + heapAt10Pct = ms.HeapAlloc + } + } + elapsed := time.Since(start) + + finalGoroutines := settleGoroutines() + finalFDs := fdCount() + + runtime.GC() + var ms runtime.MemStats + runtime.ReadMemStats(&ms) + + t.Logf("soak: cycles=%d elapsed=%s goroutines(baseline=%d final=%d) heap(10%%-mark=%d final=%d) fds(baseline=%d final=%d)", + soakCycles, elapsed, baselineGoroutines, finalGoroutines, heapAt10Pct, ms.HeapAlloc, baselineFDs, finalFDs) + + if finalGoroutines > baselineGoroutines { + t.Errorf("goroutine count grew: baseline=%d final=%d", baselineGoroutines, finalGoroutines) + } + if heapAt10Pct > 0 && ms.HeapAlloc > 2*heapAt10Pct { + t.Errorf("heap did not plateau: 10%%-mark=%d final=%d (final > 2x 10%%-mark)", heapAt10Pct, ms.HeapAlloc) + } + if runtime.GOOS == "linux" && baselineFDs >= 0 && finalFDs > baselineFDs { + t.Errorf("fd count grew: baseline=%d final=%d", baselineFDs, finalFDs) + } +} From c01e9b31846618517c65050bf0ef4c0f9c461236 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 11:57:25 -0400 Subject: [PATCH 31/87] fix(artwork): propagate transient artist image errors to the worker callGetImage swallowed all agent errors, so an agent outage surfaced as ErrNotFound and the worker settled artist artwork as a definitive absent (and reset the breaker). Add an additive ArtistImageResult path that returns the underlying agent error on transient failure while keeping ArtistImage byte-identical for existing callers; the worker's artist external step uses it via fromArtistExternalResult. --- core/artwork/e2e/helpers_test.go | 3 ++ core/artwork/sources.go | 12 ++++++ core/external/provider.go | 42 +++++++++++++++++---- core/external/provider_artistimage_test.go | 43 ++++++++++++++++++++++ server/subsonic/e2e/e2e_suite_test.go | 4 ++ 5 files changed, 96 insertions(+), 8 deletions(-) diff --git a/core/artwork/e2e/helpers_test.go b/core/artwork/e2e/helpers_test.go index e3abca097..4e99f7a5c 100644 --- a/core/artwork/e2e/helpers_test.go +++ b/core/artwork/e2e/helpers_test.go @@ -177,6 +177,9 @@ func (n *noopProvider) TopSongs(context.Context, string, int) (model.MediaFiles, func (n *noopProvider) ArtistImage(context.Context, string) (*url.URL, error) { return nil, model.ErrNotFound } +func (n *noopProvider) ArtistImageResult(context.Context, string) (*url.URL, error) { + return nil, model.ErrNotFound +} func (n *noopProvider) AlbumImage(context.Context, string) (*url.URL, error) { return nil, model.ErrNotFound } diff --git a/core/artwork/sources.go b/core/artwork/sources.go index 04a9257fb..d4ad0a2a7 100644 --- a/core/artwork/sources.go +++ b/core/artwork/sources.go @@ -198,6 +198,18 @@ func fromArtistExternalSource(ctx context.Context, ar model.Artist, provider ext } } +// fromArtistExternalResult is the worker's artist external step: via ArtistImageResult a +// transient agent failure surfaces as an error (extError) rather than settling as absent. +func fromArtistExternalResult(ctx context.Context, ar model.Artist, provider external.Provider) sourceFunc { + return func() (io.ReadCloser, string, error) { + imageUrl, err := provider.ArtistImageResult(ctx, ar.ID) + if err != nil { + return nil, "", err + } + return fromURL(ctx, imageUrl) + } +} + func fromAlbumExternalSource(ctx context.Context, al model.Album, provider external.Provider) sourceFunc { return func() (io.ReadCloser, string, error) { imageUrl, err := provider.AlbumImage(ctx, al.ID) diff --git a/core/external/provider.go b/core/external/provider.go index 459e8a205..42da7eb45 100644 --- a/core/external/provider.go +++ b/core/external/provider.go @@ -36,6 +36,8 @@ type Provider interface { SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) TopSongs(ctx context.Context, artist string, count int) (model.MediaFiles, error) ArtistImage(ctx context.Context, id string) (*url.URL, error) + // ArtistImageResult is like ArtistImage but reports a transient agent failure as a real error, not ErrNotFound. + ArtistImageResult(ctx context.Context, id string) (*url.URL, error) AlbumImage(ctx context.Context, id string) (*url.URL, error) } @@ -258,7 +260,7 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au // Call all registered agents and collect information g := errgroup.Group{} g.SetLimit(2) - g.Go(func() error { e.callGetImage(ctx, e.ag, &artist); return nil }) + g.Go(func() error { _ = e.callGetImage(ctx, e.ag, &artist); return nil }) g.Go(func() error { e.callGetBiography(ctx, e.ag, &artist); return nil }) g.Go(func() error { e.callGetURL(ctx, e.ag, &artist); return nil }) g.Go(func() error { e.callGetSimilarArtists(ctx, e.ag, &artist, maxSimilarArtists, true); return nil }) @@ -371,18 +373,35 @@ func (e *provider) similarSongsFallback(ctx context.Context, id string, count in } func (e *provider) ArtistImage(ctx context.Context, id string) (*url.URL, error) { + u, _, err := e.artistImage(ctx, id) + return u, err +} + +// ArtistImageResult is like ArtistImage but surfaces a transient agent failure as the +// real error, so an agent outage is not mistaken for a definitive no-image (ErrNotFound). +func (e *provider) ArtistImageResult(ctx context.Context, id string) (*url.URL, error) { + u, agentErr, err := e.artistImage(ctx, id) + if agentErr != nil && errors.Is(err, model.ErrNotFound) { + return nil, agentErr + } + return u, err +} + +// artistImage returns the agent error (agentErr) separately from the caller-facing err, +// so ArtistImageResult can tell "agent errored" apart from "definitively no image". +func (e *provider) artistImage(ctx context.Context, id string) (u *url.URL, agentErr error, err error) { artist, err := e.getArtist(ctx, id) if err != nil { - return nil, err + return nil, nil, err } imageUrl := artist.ArtistImageUrl() if imageUrl == "" { // No cached URL — must fetch from external source synchronously - e.callGetImage(ctx, e.ag, &artist) + agentErr = e.callGetImage(ctx, e.ag, &artist) if utils.IsCtxDone(ctx) { log.Warn(ctx, "ArtistImage call canceled", ctx.Err()) - return nil, ctx.Err() + return nil, agentErr, ctx.Err() } imageUrl = artist.ArtistImageUrl() } else { @@ -396,9 +415,10 @@ func (e *provider) ArtistImage(ctx context.Context, id string) (*url.URL, error) } if imageUrl == "" { - return nil, model.ErrNotFound + return nil, agentErr, model.ErrNotFound } - return url.Parse(imageUrl) + u, err = url.Parse(imageUrl) + return u, agentErr, err } func (e *provider) AlbumImage(ctx context.Context, id string) (*url.URL, error) { @@ -519,10 +539,15 @@ func (e *provider) callGetBiography(ctx context.Context, agent agents.ArtistBiog artist.Biography = strings.ReplaceAll(bio, " images[j].Size }) @@ -535,6 +560,7 @@ func (e *provider) callGetImage(ctx context.Context, agent agents.ArtistImageRet if len(images) >= 3 { artist.SmallImageUrl = images[2].URL } + return nil } func (e *provider) callGetSimilarArtists(ctx context.Context, agent agents.ArtistSimilarRetriever, artist *auxArtist, diff --git a/core/external/provider_artistimage_test.go b/core/external/provider_artistimage_test.go index 79612d651..621aac574 100644 --- a/core/external/provider_artistimage_test.go +++ b/core/external/provider_artistimage_test.go @@ -330,6 +330,49 @@ var _ = Describe("Provider - ArtistImage", func() { Expect(logBuf.String()).To(ContainSubstring("Artist image info expired, enqueuing background refresh")) }) + Describe("ArtistImageResult", func() { + It("returns the real agent error on a transient failure, not ErrNotFound", func() { + agentErr := errors.New("agent timed out") + mockImageAgent.Mock = mock.Mock{} + mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").Return(nil, agentErr).Once() + + imgURL, err := provider.ArtistImageResult(ctx, "artist-1") + + Expect(err).To(MatchError(agentErr)) + Expect(err).ToNot(MatchError(model.ErrNotFound)) + Expect(imgURL).To(BeNil()) + }) + + It("returns ErrNotFound when the agent definitively has no image", func() { + mockImageAgent.Mock = mock.Mock{} + mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").Return(nil, agents.ErrNotFound).Once() + + imgURL, err := provider.ArtistImageResult(ctx, "artist-1") + + Expect(err).To(MatchError(model.ErrNotFound)) + Expect(imgURL).To(BeNil()) + }) + + It("returns ErrNotFound when the agent returns no images without error", func() { + mockImageAgent.Mock = mock.Mock{} + mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").Return([]agents.ExternalImage{}, nil).Once() + + imgURL, err := provider.ArtistImageResult(ctx, "artist-1") + + Expect(err).To(MatchError(model.ErrNotFound)) + Expect(imgURL).To(BeNil()) + }) + + It("returns the largest image URL on success", func() { + expectedURL, _ := url.Parse("http://example.com/large.jpg") + + imgURL, err := provider.ArtistImageResult(ctx, "artist-1") + + Expect(err).ToNot(HaveOccurred()) + Expect(imgURL).To(Equal(expectedURL)) + }) + }) + Context("Unicode handling in artist names", func() { var artistWithEnDash *model.Artist var expectedURL *url.URL diff --git a/server/subsonic/e2e/e2e_suite_test.go b/server/subsonic/e2e/e2e_suite_test.go index 6875b6370..98ad6d1ee 100644 --- a/server/subsonic/e2e/e2e_suite_test.go +++ b/server/subsonic/e2e/e2e_suite_test.go @@ -361,6 +361,10 @@ func (n noopProvider) ArtistImage(context.Context, string) (*url.URL, error) { return nil, model.ErrNotFound } +func (n noopProvider) ArtistImageResult(context.Context, string) (*url.URL, error) { + return nil, model.ErrNotFound +} + func (n noopProvider) AlbumImage(context.Context, string) (*url.URL, error) { return nil, model.ErrNotFound } From 454fd248339484ad415a79a64284b3b5eb544555 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 11:57:32 -0400 Subject: [PATCH 32/87] fix(artwork): resolve full playlist source chain resolvePlaylist only built the generated grid, dropping the uploaded-image, sidecar and ExternalImageURL sources the old reader_playlist.go chain serves. Port the full chain before the grid fallback: uploaded (upload), sidecar (folder), and ExternalImageURL routed through extGate with the same extError semantics as the other external steps. Also rewires the artist external step onto ArtistImageResult. --- core/artwork/resolve.go | 46 +++++++++++++++++++++++--- core/artwork/resolve_test.go | 64 ++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 5 deletions(-) diff --git a/core/artwork/resolve.go b/core/artwork/resolve.go index 5aafdf5c5..272234c9d 100644 --- a/core/artwork/resolve.go +++ b/core/artwork/resolve.go @@ -10,6 +10,7 @@ import ( "image/png" "io" "io/fs" + "net/url" "os" "strings" @@ -29,7 +30,7 @@ type resolution struct { extError bool // an external source errored/timed out (forces failed, never absent) } -// extGateFunc is an alias for the external-step wrapper Task 4 injects (rate +// extGateFunc is an alias for the external-step wrapper the worker injects (rate // limiter + circuit breaker); resolveItem defaults to a plain passthrough. type extGateFunc = func(func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) @@ -137,7 +138,7 @@ func resolveArtist(ctx context.Context, ds model.DataStore, prov external.Provid pattern = strings.TrimSpace(pattern) switch { case pattern == "external": - if res, ok, isErr := resolveExternalStep(extGate, fromArtistExternalSource(ctx, *ar, prov)); ok { + if res, ok, isErr := resolveExternalStep(extGate, fromArtistExternalResult(ctx, *ar, prov)); ok { return res, nil } else if isErr { extErr = true @@ -165,20 +166,33 @@ func resolveArtist(ctx context.Context, ds model.DataStore, prov external.Provid return resolution{extError: extErr}, nil } -// resolvePlaylist ports the 2x2 generated grid from reader_playlist.go, -// sourcing tiles through resolveAlbum instead of the old cached reader. +// resolvePlaylist ports reader_playlist.go's chain: uploaded image, sidecar, +// ExternalImageURL, then the generated 2x2 grid sourced through resolveAlbum. func resolvePlaylist(ctx context.Context, ds model.DataStore, prov external.Provider, ffm ffmpeg.FFmpeg, playlistID string, extGate extGateFunc) (resolution, error) { pl, err := ds.Playlist(ctx).Get(playlistID) if err != nil { return resolution{}, err } + + var extErr bool + if res, ok := resolveLocalFile(pl.UploadedImagePath(), "upload"); ok { + return res, nil + } + if res, ok := resolveLocalFile(findPlaylistSidecarPath(ctx, pl.Path), "folder"); ok { + return res, nil + } + if res, ok, isErr := resolveExternalStep(extGate, fromPlaylistExternalSource(ctx, *pl)); ok { + return res, nil + } else if isErr { + extErr = true + } + albumIDs, err := ds.Playlist(ctx).Tracks(pl.ID, false).GetAlbumIDs(model.QueryOptions{Max: 4, Sort: "random()"}) if err != nil { return resolution{}, err } var tiles []image.Image - var extErr bool var tileErr error // first internal (non-external) tile failure, e.g. album deleted mid-flight for _, albumID := range albumIDs { res, err := resolveAlbum(ctx, ds, prov, ffm, albumID, extGate) @@ -246,6 +260,28 @@ func resolveExternalStep(extGate extGateFunc, sf func() (io.ReadCloser, string, return resolution{}, false, err != nil && !errors.Is(err, model.ErrNotFound) } +// fromPlaylistExternalSource mirrors reader_playlist.go's ExternalImageURL step: +// a remote URL (gated) when M3U external art is enabled, else a local file path. +func fromPlaylistExternalSource(ctx context.Context, pl model.Playlist) sourceFunc { + return func() (io.ReadCloser, string, error) { + imgURL := pl.ExternalImageURL + if imgURL == "" { + return nil, "", nil + } + parsed, err := url.Parse(imgURL) + if err != nil { + return nil, "", err + } + if parsed.Scheme == "http" || parsed.Scheme == "https" { + if !conf.Server.EnableM3UExternalAlbumArt { + return nil, "", nil + } + return fromURL(ctx, parsed) + } + return fromLocalFile(imgURL)() + } +} + func resolveEmbedded(ctx context.Context, lib libraryView, ffm ffmpeg.FFmpeg, embedRel string) (resolution, bool) { if embedRel == "" { return resolution{}, false diff --git a/core/artwork/resolve_test.go b/core/artwork/resolve_test.go index c4ccc3733..cab757dbb 100644 --- a/core/artwork/resolve_test.go +++ b/core/artwork/resolve_test.go @@ -40,6 +40,10 @@ func (f *fakeExternalProvider) ArtistImage(ctx context.Context, id string) (*url return nil, model.ErrNotFound } +func (f *fakeExternalProvider) ArtistImageResult(ctx context.Context, id string) (*url.URL, error) { + return f.ArtistImage(ctx, id) +} + var _ = Describe("resolveItem", func() { var ( ctx context.Context @@ -334,6 +338,66 @@ var _ = Describe("resolveItem", func() { Entry("4 albums -> full grid", []string{"t1", "t2", "t3", "t4"}, tileSize-1), ) + It("resolves the uploaded image before the generated grid", func() { + tmpDir := GinkgoT().TempDir() + conf.Server.DataFolder = conf.NewDir(tmpDir) + Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "playlist"), 0755)).To(Succeed()) + imgPath := filepath.Join(tmpDir, "artwork", "playlist", "plu_test.jpg") + Expect(os.WriteFile(imgPath, []byte("uploaded playlist image"), 0600)).To(Succeed()) + + plRepo := tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "plu", Name: "Playlist", UploadedImage: "plu_test.jpg"}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1", "t2"}} + ds.MockedPlaylist = plRepo + + res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plu"}, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(res.source).To(Equal("upload")) + Expect(res.sourcePath).To(Equal(imgPath)) + }) + + It("resolves a sidecar image next to the playlist file before the grid", func() { + plDir := GinkgoT().TempDir() + Expect(os.WriteFile(filepath.Join(plDir, "list.m3u"), []byte("#EXTM3U"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(plDir, "list.jpg"), []byte("sidecar image"), 0600)).To(Succeed()) + + plRepo := tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "pls", Name: "Playlist", Path: filepath.Join(plDir, "list.m3u")}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1", "t2"}} + ds.MockedPlaylist = plRepo + + res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pls"}, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(res.source).To(Equal("folder")) + Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("list.jpg")) + }) + + It("routes ExternalImageURL through extGate and sets extError on transient failure", func() { + conf.Server.EnableM3UExternalAlbumArt = true + folderRepo.result = nil // no grid tiles, so the external failure is what surfaces + + plRepo := tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "ple", Name: "Playlist", ExternalImageURL: "http://example.com/cover.jpg"}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} + ds.MockedPlaylist = plRepo + + var extGateCalls int + extGate := func(f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { + extGateCalls++ + return nil, "", errors.New("network down") + } + + res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "ple"}, extGate) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).To(BeNil()) + Expect(res.extError).To(BeTrue()) + Expect(extGateCalls).To(Equal(1)) + }) + It("yields an empty resolution when no album has art", func() { ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ {ID: "empty1", Name: "Empty"}, From bab9b5cd3a6c508d4f7318ac07bf6fde0ff64979 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 11:57:40 -0400 Subject: [PATCH 33/87] fix(artwork): purge dangling queue rows and guard concurrent re-enqueues Queue rows for deleted entities failed forever (Get -> ErrNotFound -> failed -> capped retries, unbounded). Add ArtworkQueueRepository.PurgeDangling, called from Prune next to the item_artwork purge. Separately, the found/absent path unconditionally deleted the dequeued row, erasing a concurrent scan re-enqueue; switch to DeleteIfUnchanged, which deletes only while retry_at still matches the dequeued value (verified retry_at is the column an Enqueue upsert resets). --- core/artwork/prune.go | 9 ++++ core/artwork/prune_test.go | 15 ++++++ core/artwork/worker.go | 13 +++--- core/artwork/worker_test.go | 47 +++++++++++++++++++ model/artwork.go | 5 ++ persistence/artwork_queue_repository.go | 23 +++++++++ persistence/artwork_queue_repository_test.go | 49 ++++++++++++++++++++ tests/mock_artwork_queue_repo.go | 31 +++++++++++++ 8 files changed, 186 insertions(+), 6 deletions(-) diff --git a/core/artwork/prune.go b/core/artwork/prune.go index 5278beaa4..35eddb525 100644 --- a/core/artwork/prune.go +++ b/core/artwork/prune.go @@ -22,6 +22,15 @@ func Prune(ctx context.Context, ds model.DataStore, store *ImageStore) error { log.Info(ctx, "Prune: purged dangling item artwork state", "count", purged) } + // Queue rows for deleted entities would otherwise retry forever (Get -> not found -> failed). + queuePurged, err := ds.ArtworkQueue(ctx).PurgeDangling() + if err != nil { + return err + } + if queuePurged > 0 { + log.Info(ctx, "Prune: purged dangling artwork queue rows", "count", queuePurged) + } + // One grace cutoff for both the DB orphan check and the file sweep: files younger // than the window may belong to acquisitions whose rows aren't committed yet. cutoff := time.Now().Add(-pruneMinAge) diff --git a/core/artwork/prune_test.go b/core/artwork/prune_test.go index 3de46942a..5511b2305 100644 --- a/core/artwork/prune_test.go +++ b/core/artwork/prune_test.go @@ -59,6 +59,21 @@ var _ = Describe("Prune", func() { Expect(err).ToNot(HaveOccurred()) }) + It("purges dangling artwork_queue rows for gone entities", func() { + queueRepo := tests.CreateMockArtworkQueueRepo() + Expect(queueRepo.Enqueue( + model.ArtworkQueueItem{ItemKind: "al", ItemID: "gone-album", ImageType: model.ImageTypePrimary}, + model.ArtworkQueueItem{ItemKind: "al", ItemID: "live-album", ImageType: model.ImageTypePrimary}, + )).To(Succeed()) + queueRepo.ExistingIDs = map[string]map[string]bool{"al": {"live-album": true}} + ds.MockedArtworkQueue = queueRepo + + Expect(Prune(context.Background(), ds, store)).To(Succeed()) + + Expect(findQueued(queueRepo, "al", "gone-album")).To(BeNil()) + Expect(findQueued(queueRepo, "al", "live-album")).ToNot(BeNil()) + }) + It("deletes orphan rows and their store files, keeps referenced ones", func() { data := []byte("orphan-bytes") h, _ := HashImage(bytes.NewReader(data)) diff --git a/core/artwork/worker.go b/core/artwork/worker.go index 1ebe15527..fb20a2bbc 100644 --- a/core/artwork/worker.go +++ b/core/artwork/worker.go @@ -27,9 +27,8 @@ const ( var errBreakerOpen = errors.New("artwork: external circuit breaker open") -// Worker drains the artwork queue and runs each item through processItem. The -// external step is rate-limited and circuit-broken; prune is serialized against -// in-flight acquisitions via pruneMu (acquisitions RLock, prune Lock). +// Worker drains the artwork queue through processItem: the external step is rate-limited +// and circuit-broken, and prune is serialized against in-flight acquisitions via pruneMu. type Worker struct { deps workerDeps limiter *rate.Limiter @@ -150,7 +149,9 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) { queue := w.deps.ds.ArtworkQueue(ctx) switch out { case outcomeFound, outcomeAbsent: - if err := queue.Delete(item.ItemKind, item.ItemID, item.ImageType); err != nil { + // DeleteIfUnchanged, not Delete: a scan that re-enqueued this row mid-flight reset + // its retry_at, so the row survives here and the next drain re-resolves it. + if err := queue.DeleteIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt); err != nil { log.Warn(ctx, "artwork: could not delete processed queue item", "kind", item.ItemKind, "id", item.ItemID, err) } case outcomeFailed: @@ -161,8 +162,8 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) { } } -// claim reserves items not already in flight, so a wake-triggered re-drain never -// double-processes an item still running from a previous cycle. +// claim reserves items not already in flight, so a row appearing twice within a single +// batch is processed once. func (w *Worker) claim(batch []model.ArtworkQueueItem) []model.ArtworkQueueItem { w.mu.Lock() defer w.mu.Unlock() diff --git a/core/artwork/worker_test.go b/core/artwork/worker_test.go index d41249fa2..058d707b8 100644 --- a/core/artwork/worker_test.go +++ b/core/artwork/worker_test.go @@ -16,6 +16,27 @@ import ( . "github.com/onsi/gomega" ) +// reenqueueOnDequeue simulates a concurrent scan Enqueue between DequeueBatch and the +// worker's delete by bumping retry_at, so a DeleteIfUnchanged on the dequeued value no-ops. +type reenqueueOnDequeue struct { + *tests.MockArtworkQueueRepo + done bool +} + +func (r *reenqueueOnDequeue) DequeueBatch(n int) ([]model.ArtworkQueueItem, error) { + items, err := r.MockArtworkQueueRepo.DequeueBatch(n) + if !r.done && len(items) > 0 { + r.done = true + for k, it := range r.Data { + if it.ItemKind == items[0].ItemKind && it.ItemID == items[0].ItemID { + it.RetryAt = items[0].RetryAt.Add(time.Minute) + r.Data[k] = it + } + } + } + return items, err +} + func findQueued(q *tests.MockArtworkQueueRepo, kind, id string) *model.ArtworkQueueItem { for _, it := range q.Data { if it.ItemKind == kind && it.ItemID == id { @@ -114,6 +135,32 @@ var _ = Describe("Worker", func() { Expect(err).To(MatchError(model.ErrNotFound), "a timeout must never settle on absent") }) + It("keeps a row re-enqueued between dequeue and delete", func() { + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }} + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al7", Name: "Album", FolderIDs: []string{"f1"}}, + }) + racing := &reenqueueOnDequeue{MockArtworkQueueRepo: queueRepo} + ds.MockedArtworkQueue = racing + w = NewWorker(ds, store, prov, ffm) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ + ItemKind: "al", ItemID: "al7", Priority: model.ArtworkPriorityScan, + })).To(Succeed()) + + n, err := w.drain(ctx, 1) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(1)) + + // The concurrent re-enqueue changed retry_at, so the found-path delete was a no-op. + Expect(findQueued(queueRepo, "al", "al7")).ToNot(BeNil()) + ia, err := artRepo.GetItemArtwork("al", "al7", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("folder")) + }) + It("returns zero when the queue is empty", func() { n, err := w.drain(ctx, 2) Expect(err).ToNot(HaveOccurred()) diff --git a/model/artwork.go b/model/artwork.go index 73db00ca5..0aec6489a 100644 --- a/model/artwork.go +++ b/model/artwork.go @@ -89,7 +89,12 @@ type ArtworkQueueRepository interface { // 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 + // DeleteIfUnchanged deletes the row only if its retry_at still matches retryAt, so a + // concurrent re-enqueue (which resets retry_at) survives instead of being erased. + DeleteIfUnchanged(kind, id, imageType string, retryAt time.Time) error Count() (int64, error) // EnqueueStaleAbsent inserts queue rows (priority Recheck) for absent states older than cutoff. EnqueueStaleAbsent(kind string, attemptedBefore time.Time) (int64, error) + // PurgeDangling removes queue rows whose entity no longer exists. + PurgeDangling() (int64, error) } diff --git a/persistence/artwork_queue_repository.go b/persistence/artwork_queue_repository.go index ced5e9913..eb5f21ecd 100644 --- a/persistence/artwork_queue_repository.go +++ b/persistence/artwork_queue_repository.go @@ -70,6 +70,29 @@ func (r *artworkQueueRepository) Delete(kind, id, imageType string) error { return r.delete(Eq{"item_kind": kind, "item_id": id, "image_type": imageType}) } +// DeleteIfUnchanged deletes the row only while its retry_at still equals the dequeued +// value; a concurrent Enqueue resets retry_at, so the row survives to be re-resolved. +func (r *artworkQueueRepository) DeleteIfUnchanged(kind, id, imageType string, retryAt time.Time) error { + return r.delete(Eq{"item_kind": kind, "item_id": id, "image_type": imageType, "retry_at": retryAt}) +} + +// PurgeDangling removes queue rows whose entity no longer exists, per kind. +func (r *artworkQueueRepository) PurgeDangling() (int64, error) { + var total int64 + for kind, table := range danglingItemArtworkKinds { + del := Delete(r.tableName).Where(And{ + Eq{"item_kind": kind}, + Expr("item_id NOT IN (SELECT id FROM " + table + ")"), + }) + c, err := r.executeSQL(del) + if err != nil { + return total, err + } + total += c + } + return total, nil +} + func (r *artworkQueueRepository) Count() (int64, error) { var res struct{ Count int64 } err := r.queryOne(Select("count(*) as count").From(r.tableName), &res) diff --git a/persistence/artwork_queue_repository_test.go b/persistence/artwork_queue_repository_test.go index 2b6f0d8c5..e96802284 100644 --- a/persistence/artwork_queue_repository_test.go +++ b/persistence/artwork_queue_repository_test.go @@ -63,6 +63,55 @@ var _ = Describe("ArtworkQueueRepository", func() { Expect(n).To(BeZero()) }) + It("DeleteIfUnchanged deletes only while retry_at is unchanged", func() { + Expect(repo.Enqueue(item("al", "d1", model.ArtworkPriorityScan))).To(Succeed()) + // Anchor retry_at in the past so it can never collide with the re-enqueue's now. + Expect(repo.MarkFailed("al", "d1", model.ImageTypePrimary, time.Now().Add(-time.Hour))).To(Succeed()) + got, err := repo.DequeueBatch(10) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(HaveLen(1)) + original := got[0].RetryAt + + // A concurrent scan re-enqueues, resetting retry_at to now. + Expect(repo.Enqueue(item("al", "d1", model.ArtworkPriorityScan))).To(Succeed()) + + // Deleting with the stale retry_at is a no-op: the re-enqueued row survives. + Expect(repo.DeleteIfUnchanged("al", "d1", model.ImageTypePrimary, original)).To(Succeed()) + n, _ := repo.Count() + Expect(n).To(Equal(int64(1))) + + // Deleting with the current retry_at removes it. + got, _ = repo.DequeueBatch(10) + Expect(got).To(HaveLen(1)) + Expect(repo.DeleteIfUnchanged("al", "d1", model.ImageTypePrimary, got[0].RetryAt)).To(Succeed()) + n, _ = repo.Count() + Expect(n).To(BeZero()) + }) + + It("purges queue rows whose entity no longer exists, per kind", func() { + Expect(repo.Enqueue( + item("al", albumSgtPeppers.ID, model.ArtworkPriorityScan), + item("al", "no-such-album", model.ArtworkPriorityScan), + item("ar", artistKraftwerk.ID, model.ArtworkPriorityScan), + item("ar", "no-such-artist", model.ArtworkPriorityScan), + item("pl", plsBest.ID, model.ArtworkPriorityScan), + item("pl", "no-such-playlist", model.ArtworkPriorityScan), + item("ra", radioWithHomePage.ID, model.ArtworkPriorityScan), + item("ra", "no-such-radio", model.ArtworkPriorityScan), + )).To(Succeed()) + + purged, err := repo.PurgeDangling() + Expect(err).ToNot(HaveOccurred()) + Expect(purged).To(Equal(int64(4))) + + got, _ := repo.DequeueBatch(100) + ids := make([]string, 0, len(got)) + for _, it := range got { + ids = append(ids, it.ItemID) + } + Expect(ids).To(ConsistOf(albumSgtPeppers.ID, artistKraftwerk.ID, plsBest.ID, radioWithHomePage.ID)) + }) + It("enqueues stale absent states for recheck", func() { awRepo := NewArtworkRepository(context.Background(), GetDBXBuilder()) old := time.Now().Add(-48 * time.Hour) diff --git a/tests/mock_artwork_queue_repo.go b/tests/mock_artwork_queue_repo.go index f62afe43a..8d01ef9c0 100644 --- a/tests/mock_artwork_queue_repo.go +++ b/tests/mock_artwork_queue_repo.go @@ -13,6 +13,8 @@ type MockArtworkQueueRepo struct { Err error // ItemArtworkSource, when set, backs EnqueueStaleAbsent with real item_artwork state. ItemArtworkSource *MockArtworkRepo + // ExistingIDs, keyed by item_kind, backs PurgeDangling; a nil per-kind map keeps that kind. + ExistingIDs map[string]map[string]bool } func CreateMockArtworkQueueRepo() *MockArtworkQueueRepo { @@ -90,6 +92,35 @@ func (m *MockArtworkQueueRepo) Delete(kind, id, imageType string) error { return nil } +func (m *MockArtworkQueueRepo) DeleteIfUnchanged(kind, id, imageType string, retryAt time.Time) error { + if m.Err != nil { + return m.Err + } + k := iaKey(kind, id, imageType) + if it, ok := m.Data[k]; ok && it.RetryAt.Equal(retryAt) { + delete(m.Data, k) + } + return nil +} + +func (m *MockArtworkQueueRepo) PurgeDangling() (int64, error) { + if m.Err != nil { + return 0, m.Err + } + var purged int64 + for k, it := range m.Data { + existing := m.ExistingIDs[it.ItemKind] + if existing == nil { + continue + } + if !existing[it.ItemID] { + delete(m.Data, k) + purged++ + } + } + return purged, nil +} + func (m *MockArtworkQueueRepo) Count() (int64, error) { if m.Err != nil { return 0, m.Err From 0fbbd013573af7f0de41b1279dc9a80d2b4ce3b7 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 11:57:48 -0400 Subject: [PATCH 34/87] style(artwork): fix comment accuracy and budget; fingerprint ArtistImageFolder Correct the inverted workerDeps.extGate comment, trim over-budget doc comments, and add conf.Server.ArtistImageFolder to the resolution fingerprint so an image-folder change re-resolves artist artwork. --- core/artwork/housekeeping.go | 4 ++-- core/artwork/housekeeping_test.go | 7 +++++++ core/artwork/processor.go | 4 ++-- core/artwork/worker_soak_test.go | 6 ++---- 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/core/artwork/housekeeping.go b/core/artwork/housekeeping.go index 6f1fa20a3..fea80eec6 100644 --- a/core/artwork/housekeeping.go +++ b/core/artwork/housekeeping.go @@ -26,8 +26,8 @@ var staleAbsentKinds = []string{"ar", "al", "pl", "ra"} // Fingerprint summarizes the config knobs that affect artwork resolution outcomes; a // change means previously resolved (or absent) state may no longer be correct. func Fingerprint() string { - raw := fmt.Sprintf("%s|%s|%s|%t|%s", - conf.Server.CoverArtPriority, conf.Server.ArtistArtPriority, + raw := fmt.Sprintf("%s|%s|%s|%s|%t|%s", + conf.Server.CoverArtPriority, conf.Server.ArtistArtPriority, conf.Server.ArtistImageFolder, conf.Server.Agents, conf.Server.EnableExternalServices, consts.Version) sum := md5.Sum([]byte(raw)) //nolint:gosec // fingerprint, not security-sensitive return hex.EncodeToString(sum[:]) diff --git a/core/artwork/housekeeping_test.go b/core/artwork/housekeeping_test.go index 8272514b0..4103d94c7 100644 --- a/core/artwork/housekeeping_test.go +++ b/core/artwork/housekeeping_test.go @@ -72,6 +72,13 @@ var _ = Describe("Housekeeping", func() { f2 := Fingerprint() Expect(f1).NotTo(Equal(f2)) }) + + It("changes when ArtistImageFolder changes", func() { + conf.Server.ArtistImageFolder = "/before" + f1 := Fingerprint() + conf.Server.ArtistImageFolder = "/after" + Expect(Fingerprint()).NotTo(Equal(f1)) + }) }) Describe("Backfill", func() { diff --git a/core/artwork/processor.go b/core/artwork/processor.go index 0c4d6c520..94262f552 100644 --- a/core/artwork/processor.go +++ b/core/artwork/processor.go @@ -31,8 +31,8 @@ const ( // thumbnailSize is the max dimension fed to blurhash. const thumbnailSize = 128 -// workerDeps are the collaborators processItem needs; extGate is nil outside -// tests, in which case resolveItem falls back to a plain passthrough. +// workerDeps are the collaborators processItem needs; extGate is set by NewWorker in +// production and nil only in tests, where resolveItem falls back to a plain passthrough. type workerDeps struct { ds model.DataStore store *ImageStore diff --git a/core/artwork/worker_soak_test.go b/core/artwork/worker_soak_test.go index f6ecef422..03ed1247c 100644 --- a/core/artwork/worker_soak_test.go +++ b/core/artwork/worker_soak_test.go @@ -19,10 +19,8 @@ import ( // performance benchmark, so it favors a stable signal over raw speed. const soakCycles = 2200 -// TestWorkerSoak drives processItem across a mix of sources (folder, embedded -// extraction, dangling refs) for many cycles, reading each acquired image back -// through ImageStore.Open, and asserts goroutines/heap plateau instead of -// growing unbounded. Skipped under -short. +// TestWorkerSoak runs processItem over many cycles across a mix of sources, asserting +// goroutines/heap plateau instead of growing unbounded (a leak guard). Skipped under -short. func TestWorkerSoak(t *testing.T) { if testing.Short() { t.Skip("skipping soak test in short mode") From c57496d50d581d5c8e591f5a8d3629516c999f30 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 12:04:17 -0400 Subject: [PATCH 35/87] fix(artwork): treat missing local playlist cover as definitive, not transient A playlist ExternalImageURL pointing at a local file that fails to open was routed through extError, causing failed/48h-retry loops that burn a rate limiter token forever instead of falling through to the generated grid. --- core/artwork/resolve.go | 5 ++++- core/artwork/resolve_test.go | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/core/artwork/resolve.go b/core/artwork/resolve.go index 272234c9d..af151ef4a 100644 --- a/core/artwork/resolve.go +++ b/core/artwork/resolve.go @@ -278,7 +278,10 @@ func fromPlaylistExternalSource(ctx context.Context, pl model.Playlist) sourceFu } return fromURL(ctx, parsed) } - return fromLocalFile(imgURL)() + // A missing/unreadable local file is a definitive miss, not a transient + // failure to retry: swallow the open error and fall through to the grid. + r, path, _ := fromLocalFile(imgURL)() + return r, path, nil } } diff --git a/core/artwork/resolve_test.go b/core/artwork/resolve_test.go index cab757dbb..a80d02ca9 100644 --- a/core/artwork/resolve_test.go +++ b/core/artwork/resolve_test.go @@ -398,6 +398,20 @@ var _ = Describe("resolveItem", func() { Expect(extGateCalls).To(Equal(1)) }) + It("treats a missing local ExternalImageURL as a definitive miss, not extError", func() { + folderRepo.result = nil // no grid tiles, so the local-file miss is what surfaces + + plRepo := tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "plm", Name: "Playlist", ExternalImageURL: "/nonexistent/path/cover.jpg"}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} + ds.MockedPlaylist = plRepo + + res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plm"}, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).To(BeNil()) + Expect(res.extError).To(BeFalse()) + }) + It("yields an empty resolution when no album has art", func() { ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ {ID: "empty1", Name: "Empty"}, From 87095fab08768d02a241311b462807fa7dd8fcc4 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 12:27:55 -0400 Subject: [PATCH 36/87] refactor(artwork): deduplicate purge loop, backfill table, and extGate alias --- core/artwork/housekeeping.go | 67 +++++++++++-------------- core/artwork/processor.go | 2 +- core/artwork/resolve.go | 12 ++--- persistence/artwork_queue_repository.go | 14 +----- persistence/artwork_repository.go | 15 ++++-- 5 files changed, 47 insertions(+), 63 deletions(-) diff --git a/core/artwork/housekeeping.go b/core/artwork/housekeeping.go index fea80eec6..c26b6790d 100644 --- a/core/artwork/housekeeping.go +++ b/core/artwork/housekeeping.go @@ -11,6 +11,7 @@ import ( "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" ) // FingerprintPropertyKey is the model.PropertyRepository key Backfill compares against @@ -46,36 +47,36 @@ func Backfill(ctx context.Context, ds model.DataStore) (bool, error) { return false, nil } - artists, err := ds.Artist(ctx).GetAll() - if err != nil { - return false, err + // Artists first: few entities, most external-dependent, so they get queue headstart. + kinds := []struct { + kind string + fetch func() ([]string, error) + }{ + {"ar", func() ([]string, error) { + as, err := ds.Artist(ctx).GetAll() + return slice.Map(as, func(a model.Artist) string { return a.ID }), err + }}, + {"al", func() ([]string, error) { + as, err := ds.Album(ctx).GetAll() + return slice.Map(as, func(a model.Album) string { return a.ID }), err + }}, + {"pl", func() ([]string, error) { + ps, err := ds.Playlist(ctx).GetAll() + return slice.Map(ps, func(p model.Playlist) string { return p.ID }), err + }}, + {"ra", func() ([]string, error) { + rs, err := ds.Radio(ctx).GetAll() + return slice.Map(rs, func(r model.Radio) string { return r.ID }), err + }}, } - if err := enqueueBackfillKind(ctx, ds, "ar", idsOf(artists, func(a model.Artist) string { return a.ID })); err != nil { - return false, err - } - - albums, err := ds.Album(ctx).GetAll() - if err != nil { - return false, err - } - if err := enqueueBackfillKind(ctx, ds, "al", idsOf(albums, func(a model.Album) string { return a.ID })); err != nil { - return false, err - } - - playlists, err := ds.Playlist(ctx).GetAll() - if err != nil { - return false, err - } - if err := enqueueBackfillKind(ctx, ds, "pl", idsOf(playlists, func(p model.Playlist) string { return p.ID })); err != nil { - return false, err - } - - radios, err := ds.Radio(ctx).GetAll() - if err != nil { - return false, err - } - if err := enqueueBackfillKind(ctx, ds, "ra", idsOf(radios, func(r model.Radio) string { return r.ID })); err != nil { - return false, err + for _, k := range kinds { + ids, err := k.fetch() + if err != nil { + return false, err + } + if err := enqueueBackfillKind(ctx, ds, k.kind, ids); err != nil { + return false, err + } } if err := props.Put(FingerprintPropertyKey, current); err != nil { @@ -98,14 +99,6 @@ func enqueueBackfillKind(ctx context.Context, ds model.DataStore, kind string, i return ds.ArtworkQueue(ctx).Enqueue(items...) } -func idsOf[T any](items []T, id func(T) string) []string { - ids := make([]string, len(items)) - for i, it := range items { - ids[i] = id(it) - } - return ids -} - // EnqueueStaleAbsentAll requeues absent-state entries older than staleAbsentAge, across // every artwork-bearing kind, for the periodic recheck job. func EnqueueStaleAbsentAll(ctx context.Context, ds model.DataStore) error { diff --git a/core/artwork/processor.go b/core/artwork/processor.go index 94262f552..8852f70b8 100644 --- a/core/artwork/processor.go +++ b/core/artwork/processor.go @@ -38,7 +38,7 @@ type workerDeps struct { store *ImageStore prov external.Provider ffmpeg ffmpeg.FFmpeg - extGate func(func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) + extGate extGateFunc } // processItem resolves one queue item end to end: find an image, hash/decode/ diff --git a/core/artwork/resolve.go b/core/artwork/resolve.go index af151ef4a..7bc5115d0 100644 --- a/core/artwork/resolve.go +++ b/core/artwork/resolve.go @@ -39,7 +39,7 @@ func passthroughExtGate(f func() (io.ReadCloser, string, error)) (io.ReadCloser, } // resolveItem walks the kind's priority chain and returns the first hit. -func resolveItem(ctx context.Context, ds model.DataStore, prov external.Provider, ffmpeg ffmpeg.FFmpeg, item model.ArtworkQueueItem, extGate func(func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error)) (resolution, error) { +func resolveItem(ctx context.Context, ds model.DataStore, prov external.Provider, ffmpeg ffmpeg.FFmpeg, item model.ArtworkQueueItem, extGate extGateFunc) (resolution, error) { if extGate == nil { extGate = passthroughExtGate } @@ -290,12 +290,10 @@ func resolveEmbedded(ctx context.Context, lib libraryView, ffm ffmpeg.FFmpeg, em return resolution{}, false } abs := lib.Abs(embedRel) - mtime := mtimeViaFS(lib.FS, embedRel) - if r, _, _ := fromTag(ctx, lib.FS, embedRel)(); r != nil { - return resolution{reader: r, source: "embedded", sourcePath: abs, refMtime: mtime}, true - } - if r, _, _ := fromFFmpegTag(ctx, ffm, abs)(); r != nil { - return resolution{reader: r, source: "embedded", sourcePath: abs, refMtime: mtime}, true + for _, sf := range []sourceFunc{fromTag(ctx, lib.FS, embedRel), fromFFmpegTag(ctx, ffm, abs)} { + if r, _, _ := sf(); r != nil { + return resolution{reader: r, source: "embedded", sourcePath: abs, refMtime: mtimeViaFS(lib.FS, embedRel)}, true + } } return resolution{}, false } diff --git a/persistence/artwork_queue_repository.go b/persistence/artwork_queue_repository.go index eb5f21ecd..4a6a68eb1 100644 --- a/persistence/artwork_queue_repository.go +++ b/persistence/artwork_queue_repository.go @@ -78,19 +78,7 @@ func (r *artworkQueueRepository) DeleteIfUnchanged(kind, id, imageType string, r // PurgeDangling removes queue rows whose entity no longer exists, per kind. func (r *artworkQueueRepository) PurgeDangling() (int64, error) { - var total int64 - for kind, table := range danglingItemArtworkKinds { - del := Delete(r.tableName).Where(And{ - Eq{"item_kind": kind}, - Expr("item_id NOT IN (SELECT id FROM " + table + ")"), - }) - c, err := r.executeSQL(del) - if err != nil { - return total, err - } - total += c - } - return total, nil + return purgeDangling(r.executeSQL, r.tableName) } func (r *artworkQueueRepository) Count() (int64, error) { diff --git a/persistence/artwork_repository.go b/persistence/artwork_repository.go index 622f47bfe..9ac32b42e 100644 --- a/persistence/artwork_repository.go +++ b/persistence/artwork_repository.go @@ -122,14 +122,15 @@ var danglingItemArtworkKinds = map[string]string{ "ra": "radio", } -func (r *artworkRepository) PurgeDanglingItemArtwork() (int64, error) { +// purgeDangling deletes rows in table whose owning entity is gone, one statement per kind. +func purgeDangling(execute func(Sqlizer) (int64, error), table string) (int64, error) { var total int64 - for kind, table := range danglingItemArtworkKinds { - del := Delete(itemArtworkTable).Where(And{ + for kind, entityTable := range danglingItemArtworkKinds { + del := Delete(table).Where(And{ Eq{"item_kind": kind}, - Expr("item_id NOT IN (SELECT id FROM " + table + ")"), + Expr("item_id NOT IN (SELECT id FROM " + entityTable + ")"), }) - c, err := r.items.executeSQL(del) + c, err := execute(del) if err != nil { return total, err } @@ -138,6 +139,10 @@ func (r *artworkRepository) PurgeDanglingItemArtwork() (int64, error) { return total, nil } +func (r *artworkRepository) PurgeDanglingItemArtwork() (int64, error) { + return purgeDangling(r.items.executeSQL, itemArtworkTable) +} + func (r *artworkRepository) GetItemArtwork(kind, id, imageType string) (*model.ItemArtwork, error) { sel := Select("*").From(itemArtworkTable). Where(Eq{"item_kind": kind, "item_id": id, "image_type": imageType}) From 67f6d8aee810301e471a09bd7c5c5633266edf13 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 12:38:40 -0400 Subject: [PATCH 37/87] fix(artwork): cap resolved image reads A user-editable ExternalImageURL can point at an arbitrarily large endpoint; a fast server could make the worker buffer hundreds of MB inside the 5s HTTP timeout. Bound the read to a fixed 20MB cap (no config knob) via io.LimitReader and fail the item if it is exceeded. --- core/artwork/processor.go | 10 +++++++++- core/artwork/processor_test.go | 21 +++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/core/artwork/processor.go b/core/artwork/processor.go index 8852f70b8..262d06518 100644 --- a/core/artwork/processor.go +++ b/core/artwork/processor.go @@ -31,6 +31,10 @@ const ( // thumbnailSize is the max dimension fed to blurhash. const thumbnailSize = 128 +// maxImageBytes caps a resolved image read: a user-editable ExternalImageURL could +// point at an arbitrarily large endpoint, and 20MB is generous for any real cover. +const maxImageBytes = 20 << 20 + // workerDeps are the collaborators processItem needs; extGate is set by NewWorker in // production and nil only in tests, where resolveItem falls back to a plain passthrough. type workerDeps struct { @@ -60,11 +64,15 @@ func processItem(ctx context.Context, deps *workerDeps, item model.ArtworkQueueI } defer res.reader.Close() - data, err := io.ReadAll(res.reader) + data, err := io.ReadAll(io.LimitReader(res.reader, maxImageBytes+1)) if err != nil { log.Warn(ctx, "artwork: failed to read resolved image", "kind", item.ItemKind, "id", item.ItemID, err) return outcomeFailed } + if len(data) > maxImageBytes { + log.Warn(ctx, "artwork: resolved image exceeds size cap", "kind", item.ItemKind, "id", item.ItemID, "source", res.source, "cap", maxImageBytes) + return outcomeFailed + } log.Debug(ctx, "artwork: read resolved image", "kind", item.ItemKind, "id", item.ItemID, "source", res.source, "bytes", len(data)) hash, err := HashImage(bytes.NewReader(data)) diff --git a/core/artwork/processor_test.go b/core/artwork/processor_test.go index a73cd4fca..64b0181bd 100644 --- a/core/artwork/processor_test.go +++ b/core/artwork/processor_test.go @@ -186,6 +186,27 @@ var _ = Describe("processItem", func() { Expect(err).To(MatchError(model.ErrNotFound)) }) + It("oversized read: a resolved image larger than the cap fails without writing state", func() { + tmpDir := GinkgoT().TempDir() + conf.Server.DataFolder = conf.NewDir(tmpDir) + Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "radio"), 0755)).To(Succeed()) + imgPath := filepath.Join(tmpDir, "artwork", "radio", "big_test.jpg") + f, err := os.Create(imgPath) + Expect(err).ToNot(HaveOccurred()) + Expect(f.Truncate(maxImageBytes + 1)).To(Succeed()) + Expect(f.Close()).To(Succeed()) + + radioRepo := tests.CreateMockedRadioRepo() + radioRepo.Data = map[string]*model.Radio{"big": {ID: "big", Name: "Radio", UploadedImage: "big_test.jpg"}} + ds.MockedRadio = radioRepo + + out := processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "big"}) + Expect(out).To(Equal(outcomeFailed)) + + _, err = artRepo.GetItemArtwork("ra", "big", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + It("store write failure: fails without writing state", func() { ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ {ID: "al7", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}, From 6afcb93a9b84889ea08f4c27f3948e395c765e70 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 12:39:15 -0400 Subject: [PATCH 38/87] fix(artwork): retry higher-priority external art after fallback hit With CoverArtPriority="external,cover.jpg", a transient external failure followed by a folder hit dropped the external error: the worker recorded found and deleted the queue row, so the configured higher-priority external art was never retried. Carry extError onto the fallback resolution and add an outcomeFoundStale that persists+serves the art but reschedules via MarkFailed, giving the external source another chance. When external later answers definitively-not-found, the hit is not stale and the row is deleted. --- core/artwork/processor.go | 6 +++++ core/artwork/processor_test.go | 22 +++++++++++++++++++ core/artwork/resolve.go | 11 ++++++++-- core/artwork/resolve_test.go | 40 ++++++++++++++++++++++++++++++++++ core/artwork/worker.go | 2 +- core/artwork/worker_test.go | 28 ++++++++++++++++++++++++ 6 files changed, 106 insertions(+), 3 deletions(-) diff --git a/core/artwork/processor.go b/core/artwork/processor.go index 262d06518..73a522daf 100644 --- a/core/artwork/processor.go +++ b/core/artwork/processor.go @@ -24,6 +24,9 @@ type outcome int const ( outcomeFound outcome = iota + // outcomeFoundStale: state was written and is served, but a higher-priority external + // step failed, so the row must retry (via MarkFailed) to give that source another chance. + outcomeFoundStale outcomeAbsent outcomeFailed ) @@ -116,6 +119,9 @@ func processItem(ctx context.Context, deps *workerDeps, item model.ArtworkQueueI log.Warn(ctx, "artwork: failed to persist item artwork state", "kind", item.ItemKind, "id", item.ItemID, err) return outcomeFailed } + if res.extError { + return outcomeFoundStale + } return outcomeFound } diff --git a/core/artwork/processor_test.go b/core/artwork/processor_test.go index 64b0181bd..876124bc3 100644 --- a/core/artwork/processor_test.go +++ b/core/artwork/processor_test.go @@ -136,6 +136,28 @@ var _ = Describe("processItem", func() { Expect(err).To(MatchError(model.ErrNotFound)) }) + It("found-stale: a fallback hit after a transient external failure persists state and returns outcomeFoundStale", func() { + conf.Server.CoverArtPriority = "external, cover.jpg" + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }} + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "alstale", Name: "Album", FolderIDs: []string{"f1"}}, + }) + prov.albumImage = func(context.Context, string) (*url.URL, error) { + return nil, errors.New("agent timed out") + } + + out := processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"}) + Expect(out).To(Equal(outcomeFoundStale)) + + ia, err := artRepo.GetItemArtwork("al", "alstale", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Hash).ToNot(BeEmpty()) + Expect(ia.Source).To(Equal("folder")) + }) + It("dedup: a second item with identical bytes skips decode and reuses the artwork row", func() { folderRepo.result = []model.Folder{{ Path: "tests/fixtures/artist/an-album", diff --git a/core/artwork/resolve.go b/core/artwork/resolve.go index 7bc5115d0..bb2a3f01b 100644 --- a/core/artwork/resolve.go +++ b/core/artwork/resolve.go @@ -27,7 +27,9 @@ type resolution struct { source string // model.ItemArtwork.Source value: "folder", "embedded", "external", "upload", "generated" sourcePath string // backing library/upload file (folder/upload: the image; embedded: the audio file); "" otherwise refMtime int64 // mtime of sourcePath at resolution time; 0 when no sourcePath - extError bool // an external source errored/timed out (forces failed, never absent) + // external source errored/timed out. With no reader: forces failed (never absent). + // On a hit: a higher-priority external step failed—serve this, but retry later. + extError bool } // extGateFunc is an alias for the external-step wrapper the worker injects (rate @@ -79,6 +81,7 @@ func resolveAlbum(ctx context.Context, ds model.DataStore, prov external.Provide switch { case pattern == "embedded": if res, ok := resolveEmbedded(ctx, lib, ffm, al.EmbedArtPath); ok { + res.extError = extErr return res, nil } case pattern == "external": @@ -89,6 +92,7 @@ func resolveAlbum(ctx context.Context, ds model.DataStore, prov external.Provide } case len(imgFiles) > 0: if res, ok := resolveFolderFile(ctx, lib, imgFiles, pattern); ok { + res.extError = extErr return res, nil } } @@ -145,6 +149,7 @@ func resolveArtist(ctx context.Context, ds model.DataStore, prov external.Provid } case pattern == "image-folder": if res, ok := resolveArtistImageFolder(ar); ok { + res.extError = extErr return res, nil } case strings.HasPrefix(pattern, "album/"): @@ -152,6 +157,7 @@ func resolveArtist(ctx context.Context, ds model.DataStore, prov external.Provid continue } if res, ok := resolveFolderFile(ctx, lib, imgFiles, strings.TrimPrefix(pattern, "album/")); ok { + res.extError = extErr return res, nil } default: @@ -159,6 +165,7 @@ func resolveArtist(ctx context.Context, ds model.DataStore, prov external.Provid continue } if res, ok := resolveArtistFolderPattern(ctx, lib, artistFolder, pattern); ok { + res.extError = extErr return res, nil } } @@ -236,7 +243,7 @@ func resolvePlaylist(ctx context.Context, ds model.DataStore, prov external.Prov if err != nil { return resolution{extError: extErr}, nil //nolint:nilerr // encode failure is a soft "no image", not a resolveItem error } - return resolution{reader: r, source: "generated"}, nil + return resolution{reader: r, source: "generated", extError: extErr}, nil } // resolveRadio ports reader_radio.go: only an uploaded image, no fallback. diff --git a/core/artwork/resolve_test.go b/core/artwork/resolve_test.go index a80d02ca9..21b742345 100644 --- a/core/artwork/resolve_test.go +++ b/core/artwork/resolve_test.go @@ -148,6 +148,46 @@ var _ = Describe("resolveItem", func() { Expect(res.extError).To(BeFalse()) }) + It("carries extError onto a fallback folder hit after a transient external failure", func() { + conf.Server.CoverArtPriority = "external, cover.jpg" + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }} + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al6", Name: "Album", FolderIDs: []string{"f1"}}, + }) + prov.albumImage = func(context.Context, string) (*url.URL, error) { + return nil, errors.New("agent timed out") + } + + res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al6"}, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(res.source).To(Equal("folder")) + Expect(res.extError).To(BeTrue()) + }) + + It("does not carry extError onto a fallback folder hit after a definitive external not-found", func() { + conf.Server.CoverArtPriority = "external, cover.jpg" + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }} + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al7", Name: "Album", FolderIDs: []string{"f1"}}, + }) + // prov.albumImage left nil -> fakeExternalProvider returns model.ErrNotFound + + res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al7"}, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(res.source).To(Equal("folder")) + Expect(res.extError).To(BeFalse()) + }) + It("routes the external step through a custom extGate", func() { conf.Server.CoverArtPriority = "external" ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ diff --git a/core/artwork/worker.go b/core/artwork/worker.go index fb20a2bbc..bc3e47af0 100644 --- a/core/artwork/worker.go +++ b/core/artwork/worker.go @@ -154,7 +154,7 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) { if err := queue.DeleteIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt); err != nil { log.Warn(ctx, "artwork: could not delete processed queue item", "kind", item.ItemKind, "id", item.ItemID, err) } - case outcomeFailed: + case outcomeFoundStale, outcomeFailed: retryAt := time.Now().Add(backoff(item.Attempts)) if err := queue.MarkFailed(item.ItemKind, item.ItemID, item.ImageType, retryAt); err != nil { log.Warn(ctx, "artwork: could not reschedule failed queue item", "kind", item.ItemKind, "id", item.ItemID, err) diff --git a/core/artwork/worker_test.go b/core/artwork/worker_test.go index 058d707b8..7865b6cfc 100644 --- a/core/artwork/worker_test.go +++ b/core/artwork/worker_test.go @@ -135,6 +135,34 @@ var _ = Describe("Worker", func() { Expect(err).To(MatchError(model.ErrNotFound), "a timeout must never settle on absent") }) + It("reschedules a found-stale item via MarkFailed while keeping its served state", func() { + conf.Server.CoverArtPriority = "external, cover.jpg" + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }} + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "alstale", Name: "Album", FolderIDs: []string{"f1"}}, + }) + prov.albumImage = func(context.Context, string) (*url.URL, error) { + return nil, errors.New("agent timed out") + } + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"})).To(Succeed()) + + n, err := w.drain(ctx, 2) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(1)) + + it := findQueued(queueRepo, "al", "alstale") + Expect(it).ToNot(BeNil(), "a found-stale row must survive for a higher-priority retry") + Expect(it.Attempts).To(Equal(1)) + Expect(it.RetryAt).To(BeTemporally(">", time.Now())) + + ia, err := artRepo.GetItemArtwork("al", "alstale", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("folder"), "the fallback art is served meanwhile") + }) + It("keeps a row re-enqueued between dequeue and delete", func() { folderRepo.result = []model.Folder{{ Path: "tests/fixtures/artist/an-album", From 5482784bfc19a5a8a0aae74623d25d9ea0eaa602 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 12:39:16 -0400 Subject: [PATCH 39/87] fix(artwork): treat playlist cover URL 404 as definitive miss The playlist ExternalImageURL step used sources.go's fromURL, which maps any non-200 to a generic error, so a stale URL returning 404/410 was classified transient: infinite backoff plus it counted toward the circuit breaker, blocking valid external work. Add a local fetch in resolve.go that maps 404/410 to model.ErrNotFound (definitive) while keeping other non-200s transient. sources.go is left untouched. --- core/artwork/resolve.go | 26 ++++++++++++++++++++++- core/artwork/resolve_test.go | 41 ++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/core/artwork/resolve.go b/core/artwork/resolve.go index bb2a3f01b..8597ec836 100644 --- a/core/artwork/resolve.go +++ b/core/artwork/resolve.go @@ -10,12 +10,15 @@ import ( "image/png" "io" "io/fs" + "net/http" "net/url" "os" "strings" + "time" "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/model" @@ -283,7 +286,7 @@ func fromPlaylistExternalSource(ctx context.Context, pl model.Playlist) sourceFu if !conf.Server.EnableM3UExternalAlbumArt { return nil, "", nil } - return fromURL(ctx, parsed) + return fetchPlaylistImageURL(ctx, parsed) } // A missing/unreadable local file is a definitive miss, not a transient // failure to retry: swallow the open error and fall through to the grid. @@ -292,6 +295,27 @@ func fromPlaylistExternalSource(ctx context.Context, pl model.Playlist) sourceFu } } +// Like sources.go's fromURL but maps 404/410 to ErrNotFound (definitive), so a stale M3U +// cover URL falls through to the grid instead of retrying forever and tripping the breaker. +func fetchPlaylistImageURL(ctx context.Context, imageURL *url.URL) (io.ReadCloser, string, error) { + hc := http.Client{Timeout: 5 * time.Second} + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageURL.String(), nil) + req.Header.Set("User-Agent", consts.HTTPUserAgent) + resp, err := hc.Do(req) //nolint:gosec + if err != nil { + return nil, "", err + } + if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone { + resp.Body.Close() + return nil, "", model.ErrNotFound + } + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + return nil, "", fmt.Errorf("error retrieving artwork from %s: %s", imageURL, resp.Status) + } + return resp.Body, imageURL.String(), nil +} + func resolveEmbedded(ctx context.Context, lib libraryView, ffm ffmpeg.FFmpeg, embedRel string) (resolution, bool) { if embedRel == "" { return resolution{}, false diff --git a/core/artwork/resolve_test.go b/core/artwork/resolve_test.go index 21b742345..96702ef33 100644 --- a/core/artwork/resolve_test.go +++ b/core/artwork/resolve_test.go @@ -5,6 +5,8 @@ import ( "errors" "image" "io" + "net/http" + "net/http/httptest" "net/url" "os" "path/filepath" @@ -452,6 +454,45 @@ var _ = Describe("resolveItem", func() { Expect(res.extError).To(BeFalse()) }) + It("treats an ExternalImageURL 404 as a definitive miss and falls through to the grid", func() { + conf.Server.EnableM3UExternalAlbumArt = true + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + plRepo := tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "pl404", Name: "Playlist", ExternalImageURL: srv.URL}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} + ds.MockedPlaylist = plRepo + + res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl404"}, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(res.source).To(Equal("generated")) + Expect(res.extError).To(BeFalse()) + }) + + It("treats an ExternalImageURL 500 as a transient failure and sets extError", func() { + conf.Server.EnableM3UExternalAlbumArt = true + folderRepo.result = nil // no grid tiles, so the external failure is what surfaces + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + plRepo := tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "pl500", Name: "Playlist", ExternalImageURL: srv.URL}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} + ds.MockedPlaylist = plRepo + + res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl500"}, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).To(BeNil()) + Expect(res.extError).To(BeTrue()) + }) + It("yields an empty resolution when no album has art", func() { ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ {ID: "empty1", Name: "Empty"}, From 3d32157403807751eed58ee1106190c10386c10c Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 13:03:38 -0400 Subject: [PATCH 40/87] test(artwork): move soak test into the Ginkgo suite --- core/artwork/worker_soak_test.go | 261 +++++++++++++++---------------- 1 file changed, 127 insertions(+), 134 deletions(-) diff --git a/core/artwork/worker_soak_test.go b/core/artwork/worker_soak_test.go index 03ed1247c..355ecb7ca 100644 --- a/core/artwork/worker_soak_test.go +++ b/core/artwork/worker_soak_test.go @@ -10,148 +10,141 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" ) // soakCycles is deliberately >2000: this is a leak regression guard, not a // performance benchmark, so it favors a stable signal over raw speed. const soakCycles = 2200 -// TestWorkerSoak runs processItem over many cycles across a mix of sources, asserting -// goroutines/heap plateau instead of growing unbounded (a leak guard). Skipped under -short. -func TestWorkerSoak(t *testing.T) { - if testing.Short() { - t.Skip("skipping soak test in short mode") - } - tests.Init(t, false) - log.SetLevel(log.LevelFatal) - defer configtest.SetupConfig()() +var _ = Describe("Worker soak", func() { + // Runs processItem over many cycles across a mix of sources, asserting + // goroutines/heap plateau instead of growing unbounded (a leak guard). Skipped under -short. + It("does not leak goroutines, heap, or fds over many acquisition cycles", func() { + if testing.Short() { + Skip("skipping soak test in short mode") + } + DeferCleanup(configtest.SetupConfig()) - repoRoot, err := os.Getwd() - if err != nil { - t.Fatal(err) - } + repoRoot, err := os.Getwd() + Expect(err).ToNot(HaveOccurred()) - libRepo := &tests.MockLibraryRepo{} - libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) - folderRepo := &fakeFolderRepo{result: []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"cover.jpg"}, - }}} - ffm := tests.NewMockFFmpeg("") - prov := &fakeExternalProvider{} - artRepo := tests.CreateMockArtworkRepo() - albumRepo := tests.CreateMockAlbumRepo() - albumRepo.SetData(model.Albums{ - {ID: "al-folder", Name: "Folder Album", FolderIDs: []string{"f1"}}, - {ID: "al-embed", Name: "Embedded Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}, + libRepo := &tests.MockLibraryRepo{} + libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) + folderRepo := &fakeFolderRepo{result: []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }}} + ffm := tests.NewMockFFmpeg("") + prov := &fakeExternalProvider{} + artRepo := tests.CreateMockArtworkRepo() + albumRepo := tests.CreateMockAlbumRepo() + albumRepo.SetData(model.Albums{ + {ID: "al-folder", Name: "Folder Album", FolderIDs: []string{"f1"}}, + {ID: "al-embed", Name: "Embedded Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}, + }) + ds := &tests.MockDataStore{ + MockedFolder: folderRepo, + MockedLibrary: libRepo, + MockedArtwork: artRepo, + MockedAlbum: albumRepo, + } + store := NewImageStore(GinkgoT().TempDir()) + deps := &workerDeps{ds: ds, store: store, prov: prov, ffmpeg: ffm} + conf.Server.CoverArtPriority = "cover.jpg, embedded" + + // Dangling refs (al/ra ids the repos don't know about) mirror an entity + // deleted after being enqueued; ds.Radio auto-provisions an empty mock repo. + items := []model.ArtworkQueueItem{ + {ItemKind: "al", ItemID: "al-folder"}, + {ItemKind: "al", ItemID: "al-embed"}, + {ItemKind: "al", ItemID: "al-does-not-exist"}, + {ItemKind: "ra", ItemID: "ra-does-not-exist"}, + } + + fdCount := func() int { + if runtime.GOOS != "linux" { + return -1 + } + entries, err := os.ReadDir("/proc/self/fd") + if err != nil { + return -1 + } + return len(entries) + } + + settleGoroutines := func() int { + // Background goroutines (GC workers, etc.) can take a moment to wind down; + // poll for two consecutive equal samples instead of trusting a single one. + prev := -1 + for range 100 { + runtime.GC() + n := runtime.NumGoroutine() + if n == prev { + return n + } + prev = n + time.Sleep(10 * time.Millisecond) + } + return prev + } + + baselineGoroutines := settleGoroutines() + baselineFDs := fdCount() + + var heapAt10Pct uint64 + start := time.Now() + for i := range soakCycles { + it := items[i%len(items)] + out := processItem(context.Background(), deps, it) + + // "Serve-adjacent" read-back: exercise the Phase 2 surfaces a caller would + // use after acquisition, not the old serving pipeline. + if out == outcomeFound { + ia, err := artRepo.GetItemArtwork(it.ItemKind, it.ItemID, model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred(), "cycle %d: GetItemArtwork", i) + art, err := artRepo.GetImage(ia.Hash) + Expect(err).ToNot(HaveOccurred(), "cycle %d: GetImage", i) + rc, err := store.Open(ia.Hash, art.Mime) + switch { + case err == nil: + _, _ = io.Copy(io.Discard, rc) + rc.Close() + case os.IsNotExist(err): + // Folder-backed art has no store file; that's expected. + default: + Expect(err).ToNot(HaveOccurred(), "cycle %d: store.Open", i) + } + } + + if i == soakCycles/10 { + runtime.GC() + var ms runtime.MemStats + runtime.ReadMemStats(&ms) + heapAt10Pct = ms.HeapAlloc + } + } + elapsed := time.Since(start) + + finalGoroutines := settleGoroutines() + finalFDs := fdCount() + + runtime.GC() + var ms runtime.MemStats + runtime.ReadMemStats(&ms) + + GinkgoWriter.Printf("soak: cycles=%d elapsed=%s goroutines(baseline=%d final=%d) heap(10%%-mark=%d final=%d) fds(baseline=%d final=%d)\n", + soakCycles, elapsed, baselineGoroutines, finalGoroutines, heapAt10Pct, ms.HeapAlloc, baselineFDs, finalFDs) + + Expect(finalGoroutines).To(BeNumerically("<=", baselineGoroutines), "goroutine count grew: baseline=%d final=%d", baselineGoroutines, finalGoroutines) + if heapAt10Pct > 0 { + Expect(ms.HeapAlloc).To(BeNumerically("<=", 2*heapAt10Pct), "heap did not plateau: 10%%-mark=%d final=%d (final > 2x 10%%-mark)", heapAt10Pct, ms.HeapAlloc) + } + if runtime.GOOS == "linux" && baselineFDs >= 0 { + Expect(finalFDs).To(BeNumerically("<=", baselineFDs), "fd count grew: baseline=%d final=%d", baselineFDs, finalFDs) + } }) - ds := &tests.MockDataStore{ - MockedFolder: folderRepo, - MockedLibrary: libRepo, - MockedArtwork: artRepo, - MockedAlbum: albumRepo, - } - store := NewImageStore(t.TempDir()) - deps := &workerDeps{ds: ds, store: store, prov: prov, ffmpeg: ffm} - conf.Server.CoverArtPriority = "cover.jpg, embedded" - - // Dangling refs (al/ra ids the repos don't know about) mirror an entity - // deleted after being enqueued; ds.Radio auto-provisions an empty mock repo. - items := []model.ArtworkQueueItem{ - {ItemKind: "al", ItemID: "al-folder"}, - {ItemKind: "al", ItemID: "al-embed"}, - {ItemKind: "al", ItemID: "al-does-not-exist"}, - {ItemKind: "ra", ItemID: "ra-does-not-exist"}, - } - - fdCount := func() int { - if runtime.GOOS != "linux" { - return -1 - } - entries, err := os.ReadDir("/proc/self/fd") - if err != nil { - return -1 - } - return len(entries) - } - - settleGoroutines := func() int { - // Background goroutines (GC workers, etc.) can take a moment to wind down; - // poll for two consecutive equal samples instead of trusting a single one. - prev := -1 - for range 100 { - runtime.GC() - n := runtime.NumGoroutine() - if n == prev { - return n - } - prev = n - time.Sleep(10 * time.Millisecond) - } - return prev - } - - baselineGoroutines := settleGoroutines() - baselineFDs := fdCount() - - var heapAt10Pct uint64 - start := time.Now() - for i := range soakCycles { - it := items[i%len(items)] - out := processItem(context.Background(), deps, it) - - // "Serve-adjacent" read-back: exercise the Phase 2 surfaces a caller would - // use after acquisition, not the old serving pipeline. - if out == outcomeFound { - ia, err := artRepo.GetItemArtwork(it.ItemKind, it.ItemID, model.ImageTypePrimary) - if err != nil { - t.Fatalf("cycle %d: GetItemArtwork: %v", i, err) - } - art, err := artRepo.GetImage(ia.Hash) - if err != nil { - t.Fatalf("cycle %d: GetImage: %v", i, err) - } - rc, err := store.Open(ia.Hash, art.Mime) - switch { - case err == nil: - _, _ = io.Copy(io.Discard, rc) - rc.Close() - case os.IsNotExist(err): - // Folder-backed art has no store file; that's expected. - default: - t.Fatalf("cycle %d: store.Open: %v", i, err) - } - } - - if i == soakCycles/10 { - runtime.GC() - var ms runtime.MemStats - runtime.ReadMemStats(&ms) - heapAt10Pct = ms.HeapAlloc - } - } - elapsed := time.Since(start) - - finalGoroutines := settleGoroutines() - finalFDs := fdCount() - - runtime.GC() - var ms runtime.MemStats - runtime.ReadMemStats(&ms) - - t.Logf("soak: cycles=%d elapsed=%s goroutines(baseline=%d final=%d) heap(10%%-mark=%d final=%d) fds(baseline=%d final=%d)", - soakCycles, elapsed, baselineGoroutines, finalGoroutines, heapAt10Pct, ms.HeapAlloc, baselineFDs, finalFDs) - - if finalGoroutines > baselineGoroutines { - t.Errorf("goroutine count grew: baseline=%d final=%d", baselineGoroutines, finalGoroutines) - } - if heapAt10Pct > 0 && ms.HeapAlloc > 2*heapAt10Pct { - t.Errorf("heap did not plateau: 10%%-mark=%d final=%d (final > 2x 10%%-mark)", heapAt10Pct, ms.HeapAlloc) - } - if runtime.GOOS == "linux" && baselineFDs >= 0 && finalFDs > baselineFDs { - t.Errorf("fd count grew: baseline=%d final=%d", baselineFDs, finalFDs) - } -} +}) From b3526c0fbad4af15a6b9a7eee854cfcb55227895 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 13:15:47 -0400 Subject: [PATCH 41/87] test(artwork): make leak and permission tests pass on linux goleak now ignores notify's nonrecursive-tree goroutines (linux uses inotify, which spawns dispatch+internal instead of darwin's recursive dispatch), and the read-only-dir prune spec skips under root, where permission bits cannot make Remove fail. --- core/artwork/artwork_suite_test.go | 6 ++++-- core/artwork/prune_test.go | 3 +++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/core/artwork/artwork_suite_test.go b/core/artwork/artwork_suite_test.go index b721200e1..e2a1a591c 100644 --- a/core/artwork/artwork_suite_test.go +++ b/core/artwork/artwork_suite_test.go @@ -23,9 +23,11 @@ func TestArtwork(t *testing.T) { // package's control, so they're ignored by exact top-function instead. defer goleak.VerifyNone(t, goleak.IgnoreTopFunction("github.com/onsi/ginkgo/v2/internal/interrupt_handler.(*InterruptHandler).registerForInterrupts.func2"), - // notify's own init() starts this dispatcher the moment it's imported - // (via core/storage/local or plugins); it never exits. + // notify's own init() starts a singleton tree the moment it's imported (via + // core/storage/local or plugins); recursive on darwin, nonrecursive on linux. goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*recursiveTree).dispatch"), + goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*nonrecursiveTree).dispatch"), + goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*nonrecursiveTree).internal"), // The old cache_warmer.go starts a goroutine per NewCacheWarmer call with // no shutdown path (dark-launch target for Phase 2, not touched here). goleak.IgnoreTopFunction("github.com/navidrome/navidrome/core/artwork.(*cacheWarmer).waitSignal"), diff --git a/core/artwork/prune_test.go b/core/artwork/prune_test.go index 5511b2305..646ad7440 100644 --- a/core/artwork/prune_test.go +++ b/core/artwork/prune_test.go @@ -189,6 +189,9 @@ var _ = Describe("Prune", func() { It("warns and continues past a store.Remove failure instead of aborting the loop", func() { tests.SkipOnWindows("uses Unix file permission bits") + if os.Geteuid() == 0 { + Skip("read-only dir cannot block root (e.g. tests in a container)") + } old := time.Now().Add(-2 * time.Hour) blocked := []byte("blocked-bytes") From d6434b99293d427f503da3342b56cd7aa09db469 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 14:02:48 -0400 Subject: [PATCH 42/87] fix(artwork): reject decompression-bomb dimensions before decoding --- core/artwork/processor.go | 7 +++++++ core/artwork/processor_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/core/artwork/processor.go b/core/artwork/processor.go index 73a522daf..58def5fc0 100644 --- a/core/artwork/processor.go +++ b/core/artwork/processor.go @@ -38,6 +38,10 @@ const thumbnailSize = 128 // point at an arbitrarily large endpoint, and 20MB is generous for any real cover. const maxImageBytes = 20 << 20 +// maxImagePixels caps declared dimensions: a tiny compressed file can declare a +// huge canvas that image.Decode would expand into gigabytes (decompression bomb). +const maxImagePixels = 64 << 20 + // workerDeps are the collaborators processItem needs; extGate is set by NewWorker in // production and nil only in tests, where resolveItem falls back to a plain passthrough. type workerDeps struct { @@ -147,6 +151,9 @@ func decodeArtwork(ctx context.Context, hash string, data []byte) (*model.Artwor if err != nil { return nil, fmt.Errorf("decode image config: %w", err) } + if int64(cfg.Width)*int64(cfg.Height) > maxImagePixels { + return nil, fmt.Errorf("image dimensions %dx%d exceed pixel cap %d", cfg.Width, cfg.Height, maxImagePixels) + } img, _, err := image.Decode(bytes.NewReader(data)) if err != nil { return nil, fmt.Errorf("decode image: %w", err) diff --git a/core/artwork/processor_test.go b/core/artwork/processor_test.go index 876124bc3..574bb106b 100644 --- a/core/artwork/processor_test.go +++ b/core/artwork/processor_test.go @@ -2,7 +2,9 @@ package artwork import ( "context" + "encoding/binary" "errors" + "hash/crc32" "net/url" "os" "path/filepath" @@ -16,6 +18,21 @@ import ( . "github.com/onsi/gomega" ) +// pngHeaderWithDims builds just a PNG signature + IHDR chunk declaring w×h. DecodeConfig +// reads the header without touching pixel data, so the body can be omitted entirely. +func pngHeaderWithDims(w, h uint32) []byte { + ihdr := make([]byte, 13) + binary.BigEndian.PutUint32(ihdr[0:], w) + binary.BigEndian.PutUint32(ihdr[4:], h) + ihdr[8] = 8 // bit depth + ihdr[9] = 2 // color type: truecolor + chunk := append([]byte("IHDR"), ihdr...) + out := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a} + out = binary.BigEndian.AppendUint32(out, uint32(len(ihdr))) + out = append(out, chunk...) + return binary.BigEndian.AppendUint32(out, crc32.ChecksumIEEE(chunk)) +} + var _ = Describe("processItem", func() { var ( ctx context.Context @@ -229,6 +246,13 @@ var _ = Describe("processItem", func() { Expect(err).To(MatchError(model.ErrNotFound)) }) + It("decompression bomb: rejects huge declared dimensions before the full decode", func() { + data := pngHeaderWithDims(50000, 50000) // 2.5 gigapixels, far above the cap + _, err := decodeArtwork(ctx, "bomb", data) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("dimensions")) + }) + It("store write failure: fails without writing state", func() { ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ {ID: "al7", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}, From bc30ce67c6ed6c1ca72177b1843d319f28e35d9b Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 14:02:52 -0400 Subject: [PATCH 43/87] fix(artwork): keep fresh re-enqueues ahead of stale failure backoff --- core/artwork/worker.go | 4 ++- core/artwork/worker_test.go | 24 +++++++++++++++++ model/artwork.go | 3 +++ persistence/artwork_queue_repository.go | 11 ++++++++ persistence/artwork_queue_repository_test.go | 28 ++++++++++++++++++++ tests/mock_artwork_queue_repo.go | 13 +++++++++ 6 files changed, 82 insertions(+), 1 deletion(-) diff --git a/core/artwork/worker.go b/core/artwork/worker.go index bc3e47af0..4be32fee8 100644 --- a/core/artwork/worker.go +++ b/core/artwork/worker.go @@ -155,8 +155,10 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) { log.Warn(ctx, "artwork: could not delete processed queue item", "kind", item.ItemKind, "id", item.ItemID, err) } case outcomeFoundStale, outcomeFailed: + // MarkFailedIfUnchanged, not MarkFailed: a scan that re-enqueued this row mid-flight reset + // retry_at, so stale backoff must not stomp its fresh, immediate eligibility. retryAt := time.Now().Add(backoff(item.Attempts)) - if err := queue.MarkFailed(item.ItemKind, item.ItemID, item.ImageType, retryAt); err != nil { + if err := queue.MarkFailedIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt, retryAt); err != nil { log.Warn(ctx, "artwork: could not reschedule failed queue item", "kind", item.ItemKind, "id", item.ItemID, err) } } diff --git a/core/artwork/worker_test.go b/core/artwork/worker_test.go index 7865b6cfc..1fe904e21 100644 --- a/core/artwork/worker_test.go +++ b/core/artwork/worker_test.go @@ -189,6 +189,30 @@ var _ = Describe("Worker", func() { Expect(ia.Source).To(Equal("folder")) }) + It("keeps a fresh re-enqueue ahead of a stale failure backoff", func() { + conf.Server.CoverArtPriority = "external" + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al8", Name: "Album"}}) + prov.albumImage = func(context.Context, string) (*url.URL, error) { + return nil, errors.New("agent timed out") + } + racing := &reenqueueOnDequeue{MockArtworkQueueRepo: queueRepo} + ds.MockedArtworkQueue = racing + w = NewWorker(ds, store, prov, ffm) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al8"})).To(Succeed()) + dequeued := findQueued(queueRepo, "al", "al8").RetryAt + + n, err := w.drain(ctx, 1) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(1)) + + // The concurrent re-enqueue reset retry_at; the failure path must not stomp it + // with stale backoff nor bump attempts, so the row stays immediately eligible. + it := findQueued(queueRepo, "al", "al8") + Expect(it).ToNot(BeNil()) + Expect(it.Attempts).To(BeZero()) + Expect(it.RetryAt).To(BeTemporally("==", dequeued.Add(time.Minute))) + }) + It("returns zero when the queue is empty", func() { n, err := w.drain(ctx, 2) Expect(err).ToNot(HaveOccurred()) diff --git a/model/artwork.go b/model/artwork.go index 0aec6489a..8db11ce69 100644 --- a/model/artwork.go +++ b/model/artwork.go @@ -88,6 +88,9 @@ type ArtworkQueueRepository interface { DequeueBatch(n int) ([]ArtworkQueueItem, error) // MarkFailed increments attempts and pushes retry_at into the future. MarkFailed(kind, id, imageType string, retryAt time.Time) error + // MarkFailedIfUnchanged applies the failure backoff only while retry_at still matches + // seenRetryAt; a concurrent re-enqueue (which resets retry_at) keeps its fresh eligibility. + MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time) error Delete(kind, id, imageType string) error // DeleteIfUnchanged deletes the row only if its retry_at still matches retryAt, so a // concurrent re-enqueue (which resets retry_at) survives instead of being erased. diff --git a/persistence/artwork_queue_repository.go b/persistence/artwork_queue_repository.go index 4a6a68eb1..59e130204 100644 --- a/persistence/artwork_queue_repository.go +++ b/persistence/artwork_queue_repository.go @@ -66,6 +66,17 @@ func (r *artworkQueueRepository) MarkFailed(kind, id, imageType string, retryAt return err } +// MarkFailedIfUnchanged applies the backoff only while retry_at still equals seenRetryAt; +// a concurrent Enqueue resets retry_at, so its fresh eligibility survives untouched. +func (r *artworkQueueRepository) MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time) error { + upd := Update(r.tableName). + Set("attempts", Expr("attempts + 1")). + Set("retry_at", retryAt). + Where(Eq{"item_kind": kind, "item_id": id, "image_type": imageType, "retry_at": seenRetryAt}) + _, err := r.executeSQL(upd) + return err +} + func (r *artworkQueueRepository) Delete(kind, id, imageType string) error { return r.delete(Eq{"item_kind": kind, "item_id": id, "image_type": imageType}) } diff --git a/persistence/artwork_queue_repository_test.go b/persistence/artwork_queue_repository_test.go index e96802284..0a6ddefdc 100644 --- a/persistence/artwork_queue_repository_test.go +++ b/persistence/artwork_queue_repository_test.go @@ -54,6 +54,34 @@ var _ = Describe("ArtworkQueueRepository", func() { Expect(got[0].Attempts).To(Equal(2)) }) + It("MarkFailedIfUnchanged applies backoff only while retry_at is unchanged", func() { + Expect(repo.Enqueue(item("al", "m1", model.ArtworkPriorityScan))).To(Succeed()) + // Anchor retry_at in the past (attempts -> 1) so it can never collide with the re-enqueue's now. + Expect(repo.MarkFailed("al", "m1", model.ImageTypePrimary, time.Now().Add(-time.Hour))).To(Succeed()) + got, err := repo.DequeueBatch(10) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(HaveLen(1)) + original := got[0].RetryAt + + // A concurrent scan re-enqueues, resetting retry_at to now. + Expect(repo.Enqueue(item("al", "m1", model.ArtworkPriorityScan))).To(Succeed()) + + // Failing with the stale retry_at is a no-op: the re-enqueued row keeps its fresh state. + future := time.Now().Add(48 * time.Hour) + Expect(repo.MarkFailedIfUnchanged("al", "m1", model.ImageTypePrimary, original, future)).To(Succeed()) + got, _ = repo.DequeueBatch(10) + Expect(got).To(HaveLen(1), "the fresh re-enqueue stays immediately eligible") + Expect(got[0].Attempts).To(Equal(1), "the stale failure must not bump attempts") + current := got[0].RetryAt + + // Failing with the current retry_at applies the backoff and bumps attempts. + Expect(repo.MarkFailedIfUnchanged("al", "m1", model.ImageTypePrimary, current, future)).To(Succeed()) + got, _ = repo.DequeueBatch(10) + Expect(got).To(BeEmpty(), "backed-off row is hidden until the future retry_at") + all, _ := repo.Count() + Expect(all).To(Equal(int64(1))) + }) + It("deletes on completion and counts", func() { Expect(repo.Enqueue(item("al", "c1", 0))).To(Succeed()) n, _ := repo.Count() diff --git a/tests/mock_artwork_queue_repo.go b/tests/mock_artwork_queue_repo.go index 8d01ef9c0..421e0dd15 100644 --- a/tests/mock_artwork_queue_repo.go +++ b/tests/mock_artwork_queue_repo.go @@ -84,6 +84,19 @@ func (m *MockArtworkQueueRepo) MarkFailed(kind, id, imageType string, retryAt ti return nil } +func (m *MockArtworkQueueRepo) MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time) error { + if m.Err != nil { + return m.Err + } + k := iaKey(kind, id, imageType) + if it, ok := m.Data[k]; ok && it.RetryAt.Equal(seenRetryAt) { + 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 From 7713a6d6b291a4a603f82daa72cd69b6054b11f0 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 14:02:56 -0400 Subject: [PATCH 44/87] fix(artwork): include M3U external art flag in the config fingerprint --- core/artwork/housekeeping.go | 4 ++-- core/artwork/housekeeping_test.go | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/core/artwork/housekeeping.go b/core/artwork/housekeeping.go index c26b6790d..1b67456bf 100644 --- a/core/artwork/housekeeping.go +++ b/core/artwork/housekeeping.go @@ -27,9 +27,9 @@ var staleAbsentKinds = []string{"ar", "al", "pl", "ra"} // Fingerprint summarizes the config knobs that affect artwork resolution outcomes; a // change means previously resolved (or absent) state may no longer be correct. func Fingerprint() string { - raw := fmt.Sprintf("%s|%s|%s|%s|%t|%s", + raw := fmt.Sprintf("%s|%s|%s|%s|%t|%t|%s", conf.Server.CoverArtPriority, conf.Server.ArtistArtPriority, conf.Server.ArtistImageFolder, - conf.Server.Agents, conf.Server.EnableExternalServices, consts.Version) + conf.Server.Agents, conf.Server.EnableExternalServices, conf.Server.EnableM3UExternalAlbumArt, consts.Version) sum := md5.Sum([]byte(raw)) //nolint:gosec // fingerprint, not security-sensitive return hex.EncodeToString(sum[:]) } diff --git a/core/artwork/housekeeping_test.go b/core/artwork/housekeeping_test.go index 4103d94c7..83ba014c2 100644 --- a/core/artwork/housekeeping_test.go +++ b/core/artwork/housekeeping_test.go @@ -79,6 +79,13 @@ var _ = Describe("Housekeeping", func() { conf.Server.ArtistImageFolder = "/after" Expect(Fingerprint()).NotTo(Equal(f1)) }) + + It("changes when EnableM3UExternalAlbumArt is toggled", func() { + conf.Server.EnableM3UExternalAlbumArt = false + f1 := Fingerprint() + conf.Server.EnableM3UExternalAlbumArt = true + Expect(Fingerprint()).NotTo(Equal(f1)) + }) }) Describe("Backfill", func() { From 55608b2d20693f7db981e4bc6793e4e04cd3e5d4 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 14:03:42 -0400 Subject: [PATCH 45/87] fix(artwork): resolve private playlists with an admin context --- core/artwork/housekeeping.go | 12 ++++++++++ core/artwork/housekeeping_test.go | 38 +++++++++++++++++++++++++++++++ core/artwork/worker.go | 3 +++ core/artwork/worker_test.go | 21 +++++++++++++++++ 4 files changed, 74 insertions(+) diff --git a/core/artwork/housekeeping.go b/core/artwork/housekeeping.go index 1b67456bf..6d4cf4f43 100644 --- a/core/artwork/housekeeping.go +++ b/core/artwork/housekeeping.go @@ -11,6 +11,7 @@ import ( "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/utils/slice" ) @@ -34,9 +35,20 @@ func Fingerprint() string { return hex.EncodeToString(sum[:]) } +// withAdminUser wraps ctx with the first admin so repos that apply a per-user visibility +// filter (playlists) expose private rows during this headless work; no admin yet -> unchanged. +func withAdminUser(ctx context.Context, ds model.DataStore) context.Context { + admin, err := ds.User(ctx).FindFirstAdmin() + if err != nil || admin == nil || admin.ID == "" { + return ctx + } + return request.WithUser(ctx, *admin) +} + // Backfill enqueues artwork resolution for every entity when the config fingerprint changed // (or was never stored), artists first so those pages resolve before the larger backlog. func Backfill(ctx context.Context, ds model.DataStore) (bool, error) { + ctx = withAdminUser(ctx, ds) current := Fingerprint() props := ds.Property(ctx) stored, err := props.DefaultGet(FingerprintPropertyKey, "") diff --git a/core/artwork/housekeeping_test.go b/core/artwork/housekeeping_test.go index 83ba014c2..1df8312e8 100644 --- a/core/artwork/housekeeping_test.go +++ b/core/artwork/housekeeping_test.go @@ -7,11 +7,35 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) +// visibilityPlaylistDS models playlist_repository's userFilter: a private playlist is only +// visible when the ctx carries an admin, so headless work must wrap ctx with one first. +type visibilityPlaylistDS struct { + *tests.MockDataStore + private model.Playlist + tracks model.PlaylistTrackRepository +} + +func (v *visibilityPlaylistDS) Playlist(ctx context.Context) model.PlaylistRepository { + repo := tests.CreateMockPlaylistRepo() + repo.TracksRepo = v.tracks + if u, ok := request.UserFrom(ctx); ok && u.IsAdmin { + repo.SetData(model.Playlists{v.private}) + } + return repo +} + +func adminUserRepo() *tests.MockedUserRepo { + repo := tests.CreateMockUserRepo() + Expect(repo.Put(&model.User{ID: "admin", UserName: "admin", IsAdmin: true})).To(Succeed()) + return repo +} + // orderTrackingQueueRepo records the item kind of each Enqueue call, so tests can // assert phase ordering (artists-first) that same-priority timestamps can't guarantee. type orderTrackingQueueRepo struct { @@ -118,6 +142,20 @@ var _ = Describe("Housekeeping", func() { Expect(stored).To(Equal(Fingerprint())) }) + It("enqueues a private playlist by resolving it under an admin context", func() { + ds.MockedUser = adminUserRepo() + vds := &visibilityPlaylistDS{ + MockDataStore: ds, + private: model.Playlist{ID: "plPrivate", OwnerID: "admin"}, + tracks: &tests.MockPlaylistTrackRepo{}, + } + + did, err := Backfill(ctx, vds) + Expect(err).ToNot(HaveOccurred()) + Expect(did).To(BeTrue()) + Expect(findQueued(queueRepo.MockArtworkQueueRepo, "pl", "plPrivate")).ToNot(BeNil()) + }) + It("enqueues artists before albums/playlists/radios, all at Backfill priority", func() { seedEntities() Expect(propRepo.Put(FingerprintPropertyKey, "stale-fingerprint")).To(Succeed()) diff --git a/core/artwork/worker.go b/core/artwork/worker.go index 4be32fee8..3b6ecc809 100644 --- a/core/artwork/worker.go +++ b/core/artwork/worker.go @@ -114,6 +114,9 @@ func (w *Worker) RunPrune(ctx context.Context) error { } func (w *Worker) drain(ctx context.Context, concurrency int) (int, error) { + // Resolved per drain, not once in Run: the worker starts at boot, possibly before any + // admin exists, so a late-created admin is picked up on the next poll (private playlists). + ctx = withAdminUser(ctx, w.deps.ds) batch, err := w.deps.ds.ArtworkQueue(ctx).DequeueBatch(2 * concurrency) if err != nil { return 0, err diff --git a/core/artwork/worker_test.go b/core/artwork/worker_test.go index 1fe904e21..8568d710e 100644 --- a/core/artwork/worker_test.go +++ b/core/artwork/worker_test.go @@ -213,6 +213,27 @@ var _ = Describe("Worker", func() { Expect(it.RetryAt).To(BeTemporally("==", dequeued.Add(time.Minute))) }) + It("resolves a private playlist under an admin context instead of failing forever", func() { + ds.MockedUser = adminUserRepo() + vds := &visibilityPlaylistDS{ + MockDataStore: ds, + private: model.Playlist{ID: "plPriv", OwnerID: "admin"}, + tracks: &tests.MockPlaylistTrackRepo{}, + } + w = NewWorker(vds, store, prov, ffm) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plPriv"})).To(Succeed()) + + n, err := w.drain(ctx, 1) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(1)) + + // Resolved as absent (no art) and removed — not stuck failing on ErrNotFound forever. + Expect(findQueued(queueRepo, "pl", "plPriv")).To(BeNil()) + ia, err := artRepo.GetItemArtwork("pl", "plPriv", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Hash).To(BeEmpty()) + }) + It("returns zero when the queue is empty", func() { n, err := w.drain(ctx, 2) Expect(err).ToNot(HaveOccurred()) From ba2290af6df469cf1d8d088617c03d422a69f2c4 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 15:28:54 -0400 Subject: [PATCH 46/87] test(artwork): convert non-synctest timing tests to Ginkgo specs TestArtworkBackoffSchedule and TestArtworkWorkerRunNoLeak needed no real *testing.T (no synctest), so move them into worker_test.go as Ginkgo specs. TestArtworkBreakerHalfOpen stays plain since testing/synctest requires a real *testing.T, matching core/scrobbler's precedent. --- core/artwork/worker_test.go | 54 +++++++++++++++++++++++++++ core/artwork/worker_timing_test.go | 60 +----------------------------- 2 files changed, 56 insertions(+), 58 deletions(-) diff --git a/core/artwork/worker_test.go b/core/artwork/worker_test.go index 8568d710e..aebbb31bc 100644 --- a/core/artwork/worker_test.go +++ b/core/artwork/worker_test.go @@ -14,6 +14,7 @@ import ( "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "go.uber.org/goleak" ) // reenqueueOnDequeue simulates a concurrent scan Enqueue between DequeueBatch and the @@ -304,5 +305,58 @@ var _ = Describe("Worker", func() { cancel() Eventually(done, time.Second).Should(Receive(BeNil())) }) + + It("does not leak goroutines after Run exits", func() { + DeferCleanup(configtest.SetupConfig()) + + ignore := goleak.IgnoreCurrent() + DeferCleanup(func() { goleak.VerifyNone(GinkgoT(), ignore) }) + + localDS := &tests.MockDataStore{MockedArtworkQueue: tests.CreateMockArtworkQueueRepo()} + lw := NewWorker(localDS, NewImageStore(GinkgoT().TempDir()), &fakeExternalProvider{}, tests.NewMockFFmpeg("")) + + runCtx, cancel := context.WithCancel(ctx) + done := make(chan error, 1) + go func() { done <- lw.Run(runCtx) }() + + time.Sleep(20 * time.Millisecond) // let the loop settle on the idle select + cancel() + Eventually(done, 2*time.Second).Should(Receive(BeNil())) + }) + }) +}) + +var _ = Describe("backoff", func() { + It("returns the expected schedule with no jitter", func() { + for _, c := range []struct { + attempts int + want time.Duration + }{ + {0, 5 * time.Minute}, + {1, 20 * time.Minute}, + {2, 80 * time.Minute}, + {3, 320 * time.Minute}, + {4, 1280 * time.Minute}, + {5, 48 * time.Hour}, + {6, 48 * time.Hour}, + } { + Expect(backoffFor(c.attempts, 0)).To(Equal(c.want), "attempt %d", c.attempts) + } + }) + + It("applies jitter proportionally", func() { + base := backoffFor(2, 0) + Expect(backoffFor(2, 0.2)).To(Equal(time.Duration(float64(base) * 1.2))) + Expect(backoffFor(2, -0.2)).To(Equal(time.Duration(float64(base) * 0.8))) + }) + + It("keeps random jitter within +/-20%", func() { + lo := time.Duration(float64(320*time.Minute) * 0.8) + hi := time.Duration(float64(320*time.Minute) * 1.2) + for range 200 { + d := backoff(3) + Expect(d).To(BeNumerically(">=", lo)) + Expect(d).To(BeNumerically("<=", hi)) + } }) }) diff --git a/core/artwork/worker_timing_test.go b/core/artwork/worker_timing_test.go index a721459c2..4bb14df3a 100644 --- a/core/artwork/worker_timing_test.go +++ b/core/artwork/worker_timing_test.go @@ -1,48 +1,16 @@ package artwork import ( - "context" "errors" "testing" "testing/synctest" "time" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/tests" . "github.com/onsi/gomega" - "go.uber.org/goleak" ) -func TestArtworkBackoffSchedule(t *testing.T) { - g := NewWithT(t) - for _, c := range []struct { - attempts int - want time.Duration - }{ - {0, 5 * time.Minute}, - {1, 20 * time.Minute}, - {2, 80 * time.Minute}, - {3, 320 * time.Minute}, - {4, 1280 * time.Minute}, - {5, 48 * time.Hour}, - {6, 48 * time.Hour}, - } { - g.Expect(backoffFor(c.attempts, 0)).To(Equal(c.want), "attempt %d", c.attempts) - } - - base := backoffFor(2, 0) - g.Expect(backoffFor(2, 0.2)).To(Equal(time.Duration(float64(base) * 1.2))) - g.Expect(backoffFor(2, -0.2)).To(Equal(time.Duration(float64(base) * 0.8))) - - lo := time.Duration(float64(320*time.Minute) * 0.8) - hi := time.Duration(float64(320*time.Minute) * 1.2) - for range 200 { - d := backoff(3) - g.Expect(d).To(BeNumerically(">=", lo)) - g.Expect(d).To(BeNumerically("<=", hi)) - } -} - +// Drives the real breaker state machine with the fake clock. Plain test: testing/synctest +// needs a *testing.T, which Ginkgo doesn't give. func TestArtworkBreakerHalfOpen(t *testing.T) { synctest.Test(t, func(t *testing.T) { g := NewWithT(t) @@ -69,27 +37,3 @@ func TestArtworkBreakerHalfOpen(t *testing.T) { g.Expect(b.allow()).To(BeTrue()) }) } - -func TestArtworkWorkerRunNoLeak(t *testing.T) { - ignore := goleak.IgnoreCurrent() - defer goleak.VerifyNone(t, ignore) - - t.Cleanup(configtest.SetupConfig()) - ds := &tests.MockDataStore{MockedArtworkQueue: tests.CreateMockArtworkQueueRepo()} - w := NewWorker(ds, NewImageStore(t.TempDir()), &fakeExternalProvider{}, tests.NewMockFFmpeg("")) - - ctx, cancel := context.WithCancel(context.Background()) - done := make(chan error, 1) - go func() { done <- w.Run(ctx) }() - - time.Sleep(20 * time.Millisecond) // let the loop settle on the idle select - cancel() - select { - case err := <-done: - if err != nil { - t.Fatalf("Run returned error on cancel: %v", err) - } - case <-time.After(2 * time.Second): - t.Fatal("Run did not exit after context cancel") - } -} From f614850ff0e6f8fe17d1758b24b80361e98c659e Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 15:45:01 -0400 Subject: [PATCH 47/87] fix(artwork): store backing-file provenance per item, not per hash --- core/artwork/processor.go | 22 ++++++------- core/artwork/processor_test.go | 60 +++++++++++++++++++++++++++++++--- 2 files changed, 66 insertions(+), 16 deletions(-) diff --git a/core/artwork/processor.go b/core/artwork/processor.go index 58def5fc0..0158ca0c6 100644 --- a/core/artwork/processor.go +++ b/core/artwork/processor.go @@ -104,7 +104,8 @@ func processItem(ctx context.Context, deps *workerDeps, item model.ArtworkQueueI } art.SizeBytes = int64(len(data)) - if err := placeBytes(deps.store, art, res, data); err != nil { + sourcePath, refMtime, err := placeBytes(deps.store, art, res, data) + if err != nil { log.Warn(ctx, "artwork: failed to write image store", "kind", item.ItemKind, "id", item.ItemID, err) return outcomeFailed } @@ -118,6 +119,8 @@ func processItem(ctx context.Context, deps *workerDeps, item model.ArtworkQueueI ImageType: item.ImageType, Hash: hash, Source: res.source, + SourcePath: sourcePath, + RefMtime: refMtime, AttemptedAt: time.Now(), }); err != nil { log.Warn(ctx, "artwork: failed to persist item artwork state", "kind", item.ItemKind, "id", item.ItemID, err) @@ -196,21 +199,16 @@ func isFileBacked(source string) bool { return source == "folder" || source == "upload" } -// placeBytes fills in art's SourcePath/RefMtime and, for sources with no library file -// backing them, writes the bytes into the store (embedded keeps its audio file provenance). -func placeBytes(store *ImageStore, art *model.Artwork, res resolution, data []byte) error { +// placeBytes reports the item's backing-file provenance (folder/upload: image, embedded: audio, +// external/generated: none) and writes the bytes into the store for the non-file-backed sources. +func placeBytes(store *ImageStore, art *model.Artwork, res resolution, data []byte) (sourcePath string, refMtime int64, err error) { if isFileBacked(res.source) { - art.SourcePath = res.sourcePath - art.RefMtime = res.refMtime - return nil + return res.sourcePath, res.refMtime, nil } - art.SourcePath = "" - art.RefMtime = 0 if res.source == "embedded" { - art.SourcePath = res.sourcePath - art.RefMtime = res.refMtime + sourcePath, refMtime = res.sourcePath, res.refMtime } - return store.Write(art.Hash, art.Mime, bytes.NewReader(data)) + return sourcePath, refMtime, store.Write(art.Hash, art.Mime, bytes.NewReader(data)) } // mimeForFormat maps an image.Decode format name to its MIME type; extForMime diff --git a/core/artwork/processor_test.go b/core/artwork/processor_test.go index 574bb106b..9ea4338fe 100644 --- a/core/artwork/processor_test.go +++ b/core/artwork/processor_test.go @@ -88,12 +88,11 @@ var _ = Describe("processItem", func() { Expect(err).ToNot(HaveOccurred()) Expect(ia.Hash).ToNot(BeEmpty()) Expect(ia.Source).To(Equal("folder")) + Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/cover.jpg")) + Expect(ia.RefMtime).To(BeNumerically(">", 0)) art, err := artRepo.GetImage(ia.Hash) Expect(err).ToNot(HaveOccurred()) - Expect(filepath.ToSlash(art.SourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/cover.jpg")) - Expect(art.RefMtime).To(BeNumerically(">", 0)) - _, err = store.Open(ia.Hash, art.Mime) Expect(os.IsNotExist(err)).To(BeTrue(), "folder-backed art must not be duplicated into the store") }) @@ -110,11 +109,11 @@ var _ = Describe("processItem", func() { ia, err := artRepo.GetItemArtwork("al", "al2", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(ia.Source).To(Equal("embedded")) + Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/test.mp3")) art, err := artRepo.GetImage(ia.Hash) Expect(err).ToNot(HaveOccurred()) Expect(art.BlurHash).ToNot(BeEmpty()) - Expect(filepath.ToSlash(art.SourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/test.mp3")) rc, err := store.Open(ia.Hash, art.Mime) Expect(err).ToNot(HaveOccurred()) @@ -207,6 +206,59 @@ var _ = Describe("processItem", func() { Expect(reused.BlurHash).To(Equal("SENTINEL")) }) + It("two items, two files, identical bytes: each item keeps its own provenance; the shared artwork row is written once", func() { + // Two distinct library files with byte-identical content resolve to the same + // hash. Provenance is per-item, so neither file's path may overwrite the other. + libRoot := GinkgoT().TempDir() + imgBytes, err := os.ReadFile(filepath.Join(repoRoot, "tests/fixtures/artist/an-album/cover.jpg")) + Expect(err).ToNot(HaveOccurred()) + for sub, mtime := range map[string]int64{"album-a": 1000, "album-b": 2000} { + dir := filepath.Join(libRoot, sub) + Expect(os.MkdirAll(dir, 0755)).To(Succeed()) + img := filepath.Join(dir, "cover.jpg") + Expect(os.WriteFile(img, imgBytes, 0600)).To(Succeed()) + Expect(os.Chtimes(img, time.Unix(mtime, 0), time.Unix(mtime, 0))).To(Succeed()) + } + libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(libRoot)}}) + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "alA", Name: "Album A", FolderIDs: []string{"fa"}}, + {ID: "alB", Name: "Album B", FolderIDs: []string{"fb"}}, + }) + + folderRepo.result = []model.Folder{{Path: "album-a", ImageFiles: []string{"cover.jpg"}}} + Expect(processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alA"})).To(Equal(outcomeFound)) + iaA, err := artRepo.GetItemArtwork("al", "alA", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(iaA.Source).To(Equal("folder")) + Expect(filepath.ToSlash(iaA.SourcePath)).To(HaveSuffix("album-a/cover.jpg")) + Expect(iaA.RefMtime).To(Equal(int64(1000))) + + // Poison the shared row's blurhash: the second item must dedup on hash, not re-decode. + poisoned := artRepo.Data[iaA.Hash] + poisoned.BlurHash = "SENTINEL" + artRepo.Data[iaA.Hash] = poisoned + + folderRepo.result = []model.Folder{{Path: "album-b", ImageFiles: []string{"cover.jpg"}}} + Expect(processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alB"})).To(Equal(outcomeFound)) + iaB, err := artRepo.GetItemArtwork("al", "alB", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(iaB.Hash).To(Equal(iaA.Hash)) + Expect(filepath.ToSlash(iaB.SourcePath)).To(HaveSuffix("album-b/cover.jpg")) + Expect(iaB.RefMtime).To(Equal(int64(2000))) + + // The first item's provenance survives the second item processing identical bytes. + iaAafter, err := artRepo.GetItemArtwork("al", "alA", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(filepath.ToSlash(iaAafter.SourcePath)).To(HaveSuffix("album-a/cover.jpg")) + Expect(iaAafter.RefMtime).To(Equal(int64(1000))) + + // One shared artwork row, and dedup preserved it untouched. + Expect(artRepo.Data).To(HaveLen(1)) + reused, err := artRepo.GetImage(iaA.Hash) + Expect(err).ToNot(HaveOccurred()) + Expect(reused.BlurHash).To(Equal("SENTINEL")) + }) + It("decode failure on found bytes: fails without writing state", func() { tmpDir := GinkgoT().TempDir() conf.Server.DataFolder = conf.NewDir(tmpDir) From bba0eab3a556a3d2e2113b5836a464b0db27ba5c Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 17:09:22 -0400 Subject: [PATCH 48/87] fix(artwork): apply image limits to playlist tile decoding decodeTile ran image.Decode on every sampled album's resolved bytes before processItem's maxImageBytes/maxImagePixels guards applied, letting an oversized or decompression-bomb tile fully decode unbounded. Enforce both caps inside decodeTile itself. --- core/artwork/resolve.go | 18 +++++++++++++++++- core/artwork/resolve_test.go | 37 ++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/core/artwork/resolve.go b/core/artwork/resolve.go index 8597ec836..984533309 100644 --- a/core/artwork/resolve.go +++ b/core/artwork/resolve.go @@ -389,8 +389,24 @@ func mtimeViaFS(fsys fs.FS, name string) int64 { // decodeTile and assembleTiles mirror playlistArtworkReader's createTile/ // createTiledImage, reusing the same rect/fillCenter cropping helpers. +// decodeTile runs on every sampled album's resolved bytes before processItem's +// own maxImageBytes/maxImagePixels guards apply, so it enforces them itself too. func decodeTile(r io.ReadCloser) (image.Image, error) { - img, _, err := image.Decode(r) + data, err := io.ReadAll(io.LimitReader(r, maxImageBytes+1)) + if err != nil { + return nil, err + } + if len(data) > maxImageBytes { + return nil, fmt.Errorf("tile image exceeds size cap %d", maxImageBytes) + } + cfg, _, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil { + return nil, err + } + if int64(cfg.Width)*int64(cfg.Height) > maxImagePixels { + return nil, fmt.Errorf("tile dimensions %dx%d exceed pixel cap %d", cfg.Width, cfg.Height, maxImagePixels) + } + img, _, err := image.Decode(bytes.NewReader(data)) if err != nil { return nil, err } diff --git a/core/artwork/resolve_test.go b/core/artwork/resolve_test.go index 96702ef33..c64ee2b53 100644 --- a/core/artwork/resolve_test.go +++ b/core/artwork/resolve_test.go @@ -1,6 +1,7 @@ package artwork import ( + "bytes" "context" "errors" "image" @@ -509,6 +510,25 @@ var _ = Describe("resolveItem", func() { Expect(res.source).To(BeEmpty()) }) + It("skips a grid tile whose declared dimensions are a decompression bomb", func() { + // End-to-end regression: a bomb-declaring tile must not break the grid. + libRoot := GinkgoT().TempDir() + Expect(os.MkdirAll(filepath.Join(libRoot, "bomb"), 0755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(libRoot, "bomb", "cover.jpg"), pngHeaderWithDims(50000, 50000), 0600)).To(Succeed()) + libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(libRoot)}}) + folderRepo.result = []model.Folder{{Path: "bomb", ImageFiles: []string{"cover.jpg"}}} + + plRepo := tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "plbomb", Name: "Playlist"}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} + ds.MockedPlaylist = plRepo + + res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plbomb"}, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).To(BeNil()) + Expect(res.source).To(BeEmpty()) + }) + It("does not resolve as absent when every sampled album fails to resolve", func() { // "missing1"/"missing2" are not in MockAlbumRepo's data, so resolveAlbum // returns a genuine (non-external) error for every sampled tile. @@ -523,3 +543,20 @@ var _ = Describe("resolveItem", func() { }) }) }) + +// decodeTile runs on every sampled album's resolved bytes before processItem's +// own guards apply, so it must enforce the same caps independently. +var _ = Describe("decodeTile", func() { + It("rejects a decompression bomb before the full decode", func() { + data := pngHeaderWithDims(50000, 50000) // 2.5 gigapixels, far above the cap + _, err := decodeTile(io.NopCloser(bytes.NewReader(data))) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("dimensions")) + }) + + It("rejects a tile larger than the size cap", func() { + data := bytes.Repeat([]byte{0}, maxImageBytes+1) + _, err := decodeTile(io.NopCloser(bytes.NewReader(data))) + Expect(err).To(HaveOccurred()) + }) +}) From b172ce429605aa5e589ca67565de268a261e7989 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 18:02:54 -0400 Subject: [PATCH 49/87] refactor(artwork): reuse auth.WithAdminUser and dedupe image cap guards --- core/artwork/housekeeping.go | 14 ++--------- core/artwork/processor.go | 46 +++++++++++++++++++++++++----------- core/artwork/resolve.go | 14 ++--------- core/artwork/worker.go | 3 ++- 4 files changed, 38 insertions(+), 39 deletions(-) diff --git a/core/artwork/housekeeping.go b/core/artwork/housekeeping.go index 6d4cf4f43..fa7196ae6 100644 --- a/core/artwork/housekeeping.go +++ b/core/artwork/housekeeping.go @@ -9,9 +9,9 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/utils/slice" ) @@ -35,20 +35,10 @@ func Fingerprint() string { return hex.EncodeToString(sum[:]) } -// withAdminUser wraps ctx with the first admin so repos that apply a per-user visibility -// filter (playlists) expose private rows during this headless work; no admin yet -> unchanged. -func withAdminUser(ctx context.Context, ds model.DataStore) context.Context { - admin, err := ds.User(ctx).FindFirstAdmin() - if err != nil || admin == nil || admin.ID == "" { - return ctx - } - return request.WithUser(ctx, *admin) -} - // Backfill enqueues artwork resolution for every entity when the config fingerprint changed // (or was never stored), artists first so those pages resolve before the larger backlog. func Backfill(ctx context.Context, ds model.DataStore) (bool, error) { - ctx = withAdminUser(ctx, ds) + ctx = auth.WithAdminUser(ctx, ds) current := Fingerprint() props := ds.Property(ctx) stored, err := props.DefaultGet(FingerprintPropertyKey, "") diff --git a/core/artwork/processor.go b/core/artwork/processor.go index 0158ca0c6..fefdebf84 100644 --- a/core/artwork/processor.go +++ b/core/artwork/processor.go @@ -71,13 +71,9 @@ func processItem(ctx context.Context, deps *workerDeps, item model.ArtworkQueueI } defer res.reader.Close() - data, err := io.ReadAll(io.LimitReader(res.reader, maxImageBytes+1)) + data, err := readCapped(res.reader) if err != nil { - log.Warn(ctx, "artwork: failed to read resolved image", "kind", item.ItemKind, "id", item.ItemID, err) - return outcomeFailed - } - if len(data) > maxImageBytes { - log.Warn(ctx, "artwork: resolved image exceeds size cap", "kind", item.ItemKind, "id", item.ItemID, "source", res.source, "cap", maxImageBytes) + log.Warn(ctx, "artwork: failed to read resolved image", "kind", item.ItemKind, "id", item.ItemID, "source", res.source, err) return outcomeFailed } log.Debug(ctx, "artwork: read resolved image", "kind", item.ItemKind, "id", item.ItemID, "source", res.source, "bytes", len(data)) @@ -147,19 +143,41 @@ func writeAbsent(ctx context.Context, repo model.ArtworkRepository, item model.A return outcomeAbsent } -// decodeArtwork builds a new Artwork row from raw bytes: dimensions, mime and a -// blurhash computed from a downscaled thumbnail. -func decodeArtwork(ctx context.Context, hash string, data []byte) (*model.Artwork, error) { +// readCapped reads r, rejecting anything over maxImageBytes. +func readCapped(r io.Reader) ([]byte, error) { + data, err := io.ReadAll(io.LimitReader(r, maxImageBytes+1)) + if err != nil { + return nil, err + } + if len(data) > maxImageBytes { + return nil, fmt.Errorf("image exceeds size cap %d", maxImageBytes) + } + return data, nil +} + +// decodeCapped rejects declared dimensions over maxImagePixels BEFORE the +// full-decode allocation, then decodes. +func decodeCapped(data []byte) (image.Image, string, error) { cfg, format, err := image.DecodeConfig(bytes.NewReader(data)) if err != nil { - return nil, fmt.Errorf("decode image config: %w", err) + return nil, "", fmt.Errorf("decode image config: %w", err) } if int64(cfg.Width)*int64(cfg.Height) > maxImagePixels { - return nil, fmt.Errorf("image dimensions %dx%d exceed pixel cap %d", cfg.Width, cfg.Height, maxImagePixels) + return nil, "", fmt.Errorf("image dimensions %dx%d exceed pixel cap %d", cfg.Width, cfg.Height, maxImagePixels) } img, _, err := image.Decode(bytes.NewReader(data)) if err != nil { - return nil, fmt.Errorf("decode image: %w", err) + return nil, "", fmt.Errorf("decode image: %w", err) + } + return img, format, nil +} + +// decodeArtwork builds a new Artwork row from raw bytes: dimensions, mime and a +// blurhash computed from a downscaled thumbnail. +func decodeArtwork(ctx context.Context, hash string, data []byte) (*model.Artwork, error) { + img, format, err := decodeCapped(data) + if err != nil { + return nil, err } thumb := makeThumbnail(img, thumbnailSize) @@ -173,8 +191,8 @@ func decodeArtwork(ctx context.Context, hash string, data []byte) (*model.Artwor return &model.Artwork{ Hash: hash, Mime: mimeForFormat(format), - Width: cfg.Width, - Height: cfg.Height, + Width: img.Bounds().Dx(), + Height: img.Bounds().Dy(), BlurHash: bh, }, nil } diff --git a/core/artwork/resolve.go b/core/artwork/resolve.go index 984533309..8a62b2999 100644 --- a/core/artwork/resolve.go +++ b/core/artwork/resolve.go @@ -392,21 +392,11 @@ func mtimeViaFS(fsys fs.FS, name string) int64 { // decodeTile runs on every sampled album's resolved bytes before processItem's // own maxImageBytes/maxImagePixels guards apply, so it enforces them itself too. func decodeTile(r io.ReadCloser) (image.Image, error) { - data, err := io.ReadAll(io.LimitReader(r, maxImageBytes+1)) + data, err := readCapped(r) if err != nil { return nil, err } - if len(data) > maxImageBytes { - return nil, fmt.Errorf("tile image exceeds size cap %d", maxImageBytes) - } - cfg, _, err := image.DecodeConfig(bytes.NewReader(data)) - if err != nil { - return nil, err - } - if int64(cfg.Width)*int64(cfg.Height) > maxImagePixels { - return nil, fmt.Errorf("tile dimensions %dx%d exceed pixel cap %d", cfg.Width, cfg.Height, maxImagePixels) - } - img, _, err := image.Decode(bytes.NewReader(data)) + img, _, err := decodeCapped(data) if err != nil { return nil, err } diff --git a/core/artwork/worker.go b/core/artwork/worker.go index 3b6ecc809..637af6efb 100644 --- a/core/artwork/worker.go +++ b/core/artwork/worker.go @@ -12,6 +12,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/ffmpeg" + "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "golang.org/x/time/rate" @@ -116,7 +117,7 @@ func (w *Worker) RunPrune(ctx context.Context) error { func (w *Worker) drain(ctx context.Context, concurrency int) (int, error) { // Resolved per drain, not once in Run: the worker starts at boot, possibly before any // admin exists, so a late-created admin is picked up on the next poll (private playlists). - ctx = withAdminUser(ctx, w.deps.ds) + ctx = auth.WithAdminUser(ctx, w.deps.ds) batch, err := w.deps.ds.ArtworkQueue(ctx).DequeueBatch(2 * concurrency) if err != nil { return 0, err From 9ce51cf575f698a93393516144e7ae0415f32751 Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 23 Jul 2026 13:20:22 -0400 Subject: [PATCH 50/87] perf(artwork): fetch only IDs for backfill enumeration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backfill enumerated every album, artist, playlist and radio via GetAll and mapped out just the ID. GetAll materializes full entities (library joins, participant/stats/tags JSON, annotation, artwork hydration), so on a large library it loaded tens of thousands of heavy structs only to read one field each — spiking transient RSS to ~1GB during the one-time upgrade backfill, a memory risk on small NAS/Pi hardware. Add GetAllIDs to the album, artist, playlist and radio repositories: it reuses each repo's base row-set filter (library visibility, artist content join, playlist userFilter) but projects only id, skipping the heavy columns and post-processing. A per-repo parity test asserts GetAllIDs returns exactly the same id set as GetAll. Verified on a 727MB / 29k-artist production DB copy: peak RSS during backfill dropped from ~1012MB to ~89MB, file descriptors flat, same 36,138 items enqueued. --- core/artwork/housekeeping.go | 21 ++++----------------- model/album.go | 1 + model/artist.go | 1 + model/playlist.go | 1 + model/radio.go | 1 + persistence/album_repository.go | 9 +++++++++ persistence/album_repository_test.go | 15 +++++++++++++++ persistence/artist_repository.go | 9 +++++++++ persistence/artist_repository_test.go | 15 +++++++++++++++ persistence/playlist_repository.go | 10 ++++++++++ persistence/playlist_repository_test.go | 15 +++++++++++++++ persistence/radio_repository.go | 8 ++++++++ persistence/radio_repository_test.go | 15 +++++++++++++++ tests/mock_album_repo.go | 12 ++++++++++++ tests/mock_artist_repo.go | 12 ++++++++++++ tests/mock_playlist_repo.go | 12 ++++++++++++ tests/mock_radio_repository.go | 12 ++++++++++++ 17 files changed, 152 insertions(+), 17 deletions(-) diff --git a/core/artwork/housekeeping.go b/core/artwork/housekeeping.go index fa7196ae6..f45c62ab6 100644 --- a/core/artwork/housekeeping.go +++ b/core/artwork/housekeeping.go @@ -12,7 +12,6 @@ import ( "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/slice" ) // FingerprintPropertyKey is the model.PropertyRepository key Backfill compares against @@ -54,22 +53,10 @@ func Backfill(ctx context.Context, ds model.DataStore) (bool, error) { kind string fetch func() ([]string, error) }{ - {"ar", func() ([]string, error) { - as, err := ds.Artist(ctx).GetAll() - return slice.Map(as, func(a model.Artist) string { return a.ID }), err - }}, - {"al", func() ([]string, error) { - as, err := ds.Album(ctx).GetAll() - return slice.Map(as, func(a model.Album) string { return a.ID }), err - }}, - {"pl", func() ([]string, error) { - ps, err := ds.Playlist(ctx).GetAll() - return slice.Map(ps, func(p model.Playlist) string { return p.ID }), err - }}, - {"ra", func() ([]string, error) { - rs, err := ds.Radio(ctx).GetAll() - return slice.Map(rs, func(r model.Radio) string { return r.ID }), err - }}, + {"ar", func() ([]string, error) { return ds.Artist(ctx).GetAllIDs() }}, + {"al", func() ([]string, error) { return ds.Album(ctx).GetAllIDs() }}, + {"pl", func() ([]string, error) { return ds.Playlist(ctx).GetAllIDs() }}, + {"ra", func() ([]string, error) { return ds.Radio(ctx).GetAllIDs() }}, } for _, k := range kinds { ids, err := k.fetch() diff --git a/model/album.go b/model/album.go index 55df8c63c..7c03ccf1e 100644 --- a/model/album.go +++ b/model/album.go @@ -142,6 +142,7 @@ type AlbumRepository interface { UpdateExternalInfo(*Album) error Get(id string) (*Album, error) GetAll(...QueryOptions) (Albums, error) + GetAllIDs(...QueryOptions) ([]string, error) GetCursor(...QueryOptions) (AlbumCursor, error) GetYears(libraryIDs ...int) ([]int, error) diff --git a/model/artist.go b/model/artist.go index f9c4bffd5..053f261ce 100644 --- a/model/artist.go +++ b/model/artist.go @@ -89,6 +89,7 @@ type ArtistRepository interface { UpdateExternalInfo(a *Artist) error Get(id string) (*Artist, error) GetAll(options ...QueryOptions) (Artists, error) + GetAllIDs(options ...QueryOptions) ([]string, error) GetCursor(options ...QueryOptions) (ArtistCursor, error) GetIndex(includeMissing bool, libraryIds []int, roles ...Role) (ArtistIndexes, error) diff --git a/model/playlist.go b/model/playlist.go index 185f6f942..b5726e4ff 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -143,6 +143,7 @@ type PlaylistRepository interface { Get(id string) (*Playlist, error) GetWithTracks(id string, refreshSmartPlaylist, includeMissing bool) (*Playlist, error) GetAll(options ...QueryOptions) (Playlists, error) + GetAllIDs(options ...QueryOptions) ([]string, error) GetCursor(options ...QueryOptions) (PlaylistCursor, error) FindByPath(path string) (*Playlist, error) Delete(id string) error diff --git a/model/radio.go b/model/radio.go index 86f27c24c..7f654b665 100644 --- a/model/radio.go +++ b/model/radio.go @@ -32,5 +32,6 @@ type RadioRepository interface { Delete(id string) error Get(id string) (*Radio, error) GetAll(options ...QueryOptions) (Radios, error) + GetAllIDs(options ...QueryOptions) ([]string, error) Put(u *Radio, colsToUpdate ...string) error } diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 2bb541003..e7b155e6f 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -254,6 +254,15 @@ func (r *albumRepository) GetAll(options ...model.QueryOptions) (model.Albums, e return res.toModels(), nil } +// GetAllIDs returns just the album IDs for the same row set as GetAll, skipping the +// heavy column projection and JSON post-processing. Used by bulk enumeration (artwork backfill). +func (r *albumRepository) GetAllIDs(options ...model.QueryOptions) ([]string, error) { + sq := r.applyLibraryFilter(r.newSelect(options...).Columns("album.id")) + ids := []string{} + err := r.queryAllSlice(sq, &ids) + return ids, err +} + func (r *albumRepository) GetCursor(options ...model.QueryOptions) (model.AlbumCursor, error) { sq := r.selectAlbum(options...) cursor, err := queryWithStableResults[dbAlbum](r.sqlRepository, sq) diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index 465617b10..ef18cf426 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -84,6 +84,21 @@ var _ = Describe("AlbumRepository", func() { }) }) + Describe("GetAllIDs", func() { + It("returns the same id set as GetAll", func() { + want, err := albumRepo.GetAll() + Expect(err).ToNot(HaveOccurred()) + Expect(want).ToNot(BeEmpty()) + wantIDs := make([]string, 0, len(want)) + for _, a := range want { + wantIDs = append(wantIDs, a.ID) + } + ids, err := albumRepo.GetAllIDs() + Expect(err).ToNot(HaveOccurred()) + Expect(ids).To(ConsistOf(wantIDs)) + }) + }) + Describe("GetAll", func() { var GetAll = func(opts ...model.QueryOptions) (model.Albums, error) { albums, err := albumRepo.GetAll(opts...) diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index b542dedb4..f26ade8ba 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -264,6 +264,15 @@ func (r *artistRepository) GetAll(options ...model.QueryOptions) (model.Artists, return res, err } +// GetAllIDs returns just the artist IDs for the same row set as GetAll, skipping the +// heavy stats/annotation columns and JSON post-processing. Used by bulk enumeration (artwork backfill). +func (r *artistRepository) GetAllIDs(options ...model.QueryOptions) ([]string, error) { + sq := r.applyLibraryFilterToArtistQuery(r.newSelect(options...).Columns("artist.id")).GroupBy("artist.id") + ids := []string{} + err := r.queryAllSlice(sq, &ids) + return ids, err +} + func (r *artistRepository) GetCursor(options ...model.QueryOptions) (model.ArtistCursor, error) { sel := r.selectArtist(options...) cursor, err := queryWithStableResults[dbArtist](r.sqlRepository, sel) diff --git a/persistence/artist_repository_test.go b/persistence/artist_repository_test.go index dc11ede36..43a7f4928 100644 --- a/persistence/artist_repository_test.go +++ b/persistence/artist_repository_test.go @@ -284,6 +284,21 @@ var _ = Describe("ArtistRepository", func() { }) }) + Describe("GetAllIDs", func() { + It("returns the same id set as GetAll", func() { + want, err := repo.GetAll() + Expect(err).ToNot(HaveOccurred()) + Expect(want).ToNot(BeEmpty()) + wantIDs := make([]string, 0, len(want)) + for _, a := range want { + wantIDs = append(wantIDs, a.ID) + } + ids, err := repo.GetAllIDs() + Expect(err).ToNot(HaveOccurred()) + Expect(ids).To(ConsistOf(wantIDs)) + }) + }) + Describe("Basic Operations", func() { Describe("Count", func() { It("returns the number of artists in the DB", func() { diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index c78e0df1e..0abf53d93 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -189,6 +189,16 @@ func (r *playlistRepository) GetAll(options ...model.QueryOptions) (model.Playli return playlists, err } +// GetAllIDs returns just the playlist IDs for the same row set as GetAll (honoring userFilter), +// skipping the owner-name join columns and annotation. Used by bulk enumeration (artwork backfill). +func (r *playlistRepository) GetAllIDs(options ...model.QueryOptions) ([]string, error) { + sq := r.newSelect(options...).Columns("playlist.id"). + Join("user on user.id = owner_id").Where(r.userFilter()) + ids := []string{} + err := r.queryAllSlice(sq, &ids) + return ids, err +} + func (r *playlistRepository) GetCursor(options ...model.QueryOptions) (model.PlaylistCursor, error) { // Same userFilter as GetAll: a cursor must not widen visibility beyond public/owned playlists. sel := r.selectPlaylist(options...).Where(r.userFilter()) diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index 00d4ff9f2..8dc86d887 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -37,6 +37,21 @@ var _ = Describe("PlaylistRepository", func() { }) }) + Describe("GetAllIDs", func() { + It("returns the same id set as GetAll", func() { + want, err := repo.GetAll() + Expect(err).ToNot(HaveOccurred()) + Expect(want).ToNot(BeEmpty()) + wantIDs := make([]string, 0, len(want)) + for _, p := range want { + wantIDs = append(wantIDs, p.ID) + } + ids, err := repo.GetAllIDs() + Expect(err).ToNot(HaveOccurred()) + Expect(ids).To(ConsistOf(wantIDs)) + }) + }) + Describe("Exists", func() { It("returns true for an existing playlist", func() { Expect(repo.Exists(plsCool.ID)).To(BeTrue()) diff --git a/persistence/radio_repository.go b/persistence/radio_repository.go index 20f82ee36..3ef900920 100644 --- a/persistence/radio_repository.go +++ b/persistence/radio_repository.go @@ -59,6 +59,14 @@ func (r *radioRepository) GetAll(options ...model.QueryOptions) (model.Radios, e return res, err } +// GetAllIDs returns just the radio IDs. Used by bulk enumeration (artwork backfill). +func (r *radioRepository) GetAllIDs(options ...model.QueryOptions) ([]string, error) { + sel := r.newSelect(options...).Columns("id") + ids := []string{} + err := r.queryAllSlice(sel, &ids) + return ids, err +} + func (r *radioRepository) Put(radio *model.Radio, colsToUpdate ...string) error { if !r.isPermitted() { return rest.ErrPermissionDenied diff --git a/persistence/radio_repository_test.go b/persistence/radio_repository_test.go index 1d6cce4f8..4b243c142 100644 --- a/persistence/radio_repository_test.go +++ b/persistence/radio_repository_test.go @@ -78,6 +78,21 @@ var _ = Describe("RadioRepository", func() { }) }) + Describe("GetAllIDs", func() { + It("returns the same id set as GetAll", func() { + want, err := repo.GetAll() + Expect(err).To(BeNil()) + Expect(want).ToNot(BeEmpty()) + wantIDs := make([]string, 0, len(want)) + for _, r := range want { + wantIDs = append(wantIDs, r.ID) + } + ids, err := repo.GetAllIDs() + Expect(err).To(BeNil()) + Expect(ids).To(ConsistOf(wantIDs)) + }) + }) + Describe("Put", func() { It("successfully updates item", func() { err := repo.Put(&model.Radio{ diff --git a/tests/mock_album_repo.go b/tests/mock_album_repo.go index cc7d66b4d..17539255b 100644 --- a/tests/mock_album_repo.go +++ b/tests/mock_album_repo.go @@ -76,6 +76,18 @@ func (m *MockAlbumRepo) GetAll(qo ...model.QueryOptions) (model.Albums, error) { return m.All, nil } +func (m *MockAlbumRepo) GetAllIDs(qo ...model.QueryOptions) ([]string, error) { + all, err := m.GetAll(qo...) + if err != nil { + return nil, err + } + ids := make([]string, len(all)) + for i, a := range all { + ids[i] = a.ID + } + return ids, nil +} + func (m *MockAlbumRepo) GetCursor(qo ...model.QueryOptions) (model.AlbumCursor, error) { res, err := m.GetAll(qo...) if err != nil { diff --git a/tests/mock_artist_repo.go b/tests/mock_artist_repo.go index e6ea7aea4..4fdd198fe 100644 --- a/tests/mock_artist_repo.go +++ b/tests/mock_artist_repo.go @@ -113,6 +113,18 @@ func (m *MockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, e return allArtists, nil } +func (m *MockArtistRepo) GetAllIDs(options ...model.QueryOptions) ([]string, error) { + all, err := m.GetAll(options...) + if err != nil { + return nil, err + } + ids := make([]string, len(all)) + for i, a := range all { + ids[i] = a.ID + } + return ids, nil +} + func (m *MockArtistRepo) GetCursor(options ...model.QueryOptions) (model.ArtistCursor, error) { res, err := m.GetAll(options...) if err != nil { diff --git a/tests/mock_playlist_repo.go b/tests/mock_playlist_repo.go index 8f8842c8e..bb1fe3379 100644 --- a/tests/mock_playlist_repo.go +++ b/tests/mock_playlist_repo.go @@ -52,6 +52,18 @@ func (m *MockPlaylistRepo) GetAll(options ...model.QueryOptions) (model.Playlist return m.All, nil } +func (m *MockPlaylistRepo) GetAllIDs(options ...model.QueryOptions) ([]string, error) { + all, err := m.GetAll(options...) + if err != nil { + return nil, err + } + ids := make([]string, len(all)) + for i, p := range all { + ids[i] = p.ID + } + return ids, nil +} + func (m *MockPlaylistRepo) GetCursor(options ...model.QueryOptions) (model.PlaylistCursor, error) { res, err := m.GetAll(options...) if err != nil { diff --git a/tests/mock_radio_repository.go b/tests/mock_radio_repository.go index c50a529e5..6d1d45f6e 100644 --- a/tests/mock_radio_repository.go +++ b/tests/mock_radio_repository.go @@ -73,6 +73,18 @@ func (m *MockedRadioRepo) GetAll(qo ...model.QueryOptions) (model.Radios, error) return m.All, nil } +func (m *MockedRadioRepo) GetAllIDs(qo ...model.QueryOptions) ([]string, error) { + all, err := m.GetAll(qo...) + if err != nil { + return nil, err + } + ids := make([]string, len(all)) + for i, r := range all { + ids[i] = r.ID + } + return ids, nil +} + func (m *MockedRadioRepo) Put(radio *model.Radio, _ ...string) error { if m.Err { return errors.New("error") From fc55e8bf16b517c124db89d2f70d77cf9477f99a Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 23 Jul 2026 14:05:10 -0400 Subject: [PATCH 51/87] feat(artwork): promote worker concurrency and external rate to real configs The artwork worker's drain speed was governed by two hidden Dev flags, DevArtworkWorkerConcurrency and DevArtworkExternalRPS, both defaulting to 2. On a large library's one-time backfill the external rate limiter is the real ceiling: every art-less item waits on it before the (rate-limited) external lookup, so the drain crawls at ~RPS items/sec while local-art items are unaffected. Promote both to documented, supported options: ArtworkWorkerConcurrency (default 4) sets local-resolution parallelism, ArtworkExternalMaxRPS (default 2, 0 = unlimited) caps external-agent lookups to stay polite to Last.fm/Deezer/etc. Operators can now trade first-backfill speed against external-API rate limits. The old Dev names still map for backward compat. --- conf/configuration.go | 10 ++++++---- core/artwork/worker.go | 6 +++--- core/artwork/worker_test.go | 2 +- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/conf/configuration.go b/conf/configuration.go index 57ee4e4bc..9eeda89de 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -57,6 +57,8 @@ type configOptions struct { ImageCacheSize string AlbumPlayCountMode string EnableArtworkPrecache bool + ArtworkWorkerConcurrency int + ArtworkExternalMaxRPS int AutoImportPlaylists bool DefaultPlaylistPublicVisibility bool PlaylistsPath string @@ -139,8 +141,6 @@ type configOptions struct { DevArtworkThrottleBacklogLimit int DevArtworkThrottleBacklogTimeout time.Duration DevArtworkThrottleBuffered bool - DevArtworkWorkerConcurrency int - DevArtworkExternalRPS int DevArtistInfoTimeToLive time.Duration DevAlbumInfoTimeToLive time.Duration DevExternalScanner bool @@ -348,6 +348,8 @@ func Load(noConfigDump bool) { mapDeprecatedOption("CoverJpegQuality", "CoverArtQuality") mapDeprecatedOption("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold") mapDeprecatedOption("EnableTranscodingCancellation", "Transcoding.EnableCancellation") + mapDeprecatedOption("DevArtworkWorkerConcurrency", "ArtworkWorkerConcurrency") + mapDeprecatedOption("DevArtworkExternalRPS", "ArtworkExternalMaxRPS") err := viper.Unmarshal(&Server, viper.DecodeHook( mapstructure.ComposeDecodeHookFunc( @@ -902,8 +904,8 @@ func setViperDefaults() { viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit) viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout) viper.SetDefault("devartworkthrottlebuffered", true) - viper.SetDefault("devartworkworkerconcurrency", 2) - viper.SetDefault("devartworkexternalrps", 2) + viper.SetDefault("artworkworkerconcurrency", 4) + viper.SetDefault("artworkexternalmaxrps", 2) viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive) viper.SetDefault("devalbuminfotimetolive", consts.AlbumInfoTimeToLive) viper.SetDefault("devexternalscanner", true) diff --git a/core/artwork/worker.go b/core/artwork/worker.go index 637af6efb..5ae40a6e7 100644 --- a/core/artwork/worker.go +++ b/core/artwork/worker.go @@ -43,8 +43,8 @@ type Worker struct { } func NewWorker(ds model.DataStore, store *ImageStore, prov external.Provider, ffmpeg ffmpeg.FFmpeg) *Worker { - rps := conf.Server.DevArtworkExternalRPS - limit := rate.Inf + rps := conf.Server.ArtworkExternalMaxRPS + limit := rate.Inf // 0 or negative disables the external throttle if rps > 0 { limit = rate.Limit(rps) } @@ -64,7 +64,7 @@ func NewWorker(ds model.DataStore, store *ImageStore, prov external.Provider, ff // leaked goroutines: each drain waits for its batch before the loop can return. func (w *Worker) Run(ctx context.Context) error { w.runCtx = ctx - concurrency := max(1, conf.Server.DevArtworkWorkerConcurrency) + concurrency := max(1, conf.Server.ArtworkWorkerConcurrency) ticker := time.NewTicker(workerPollInterval) defer ticker.Stop() for { diff --git a/core/artwork/worker_test.go b/core/artwork/worker_test.go index aebbb31bc..e1b0c985d 100644 --- a/core/artwork/worker_test.go +++ b/core/artwork/worker_test.go @@ -85,7 +85,7 @@ var _ = Describe("Worker", func() { ds.MockedAlbum = tests.CreateMockAlbumRepo() store = NewImageStore(GinkgoT().TempDir()) conf.Server.CoverArtPriority = "cover.jpg, embedded" - conf.Server.DevArtworkExternalRPS = 1000 // keep the limiter out of the way of behavior tests + conf.Server.ArtworkExternalMaxRPS = 1000 // keep the limiter out of the way of behavior tests w = NewWorker(ds, store, prov, ffm) }) From f34a386137ee44f4e4c5c5d4a640b917634afee4 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 19:23:01 -0400 Subject: [PATCH 52/87] fix(deezer): never return empty-image-id placeholder pictures --- adapters/deezer/deezer.go | 13 ++++++++- adapters/deezer/deezer_test.go | 48 ++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/adapters/deezer/deezer.go b/adapters/deezer/deezer.go index d8e832cf1..742b8b1a5 100644 --- a/adapters/deezer/deezer.go +++ b/adapters/deezer/deezer.go @@ -70,16 +70,27 @@ func (s *deezerAgent) GetArtistImages(ctx context.Context, _, name, _ string) ([ {artist.PictureSmall, deezerApiPictureSmallSize}, } for _, imgData := range possibleImages { - if imgData.URL != "" { + if imgData.URL != "" && !isPlaceholderPicture(imgData.URL) { res = append(res, agents.ExternalImage{ URL: imgData.URL, Size: imgData.Size, }) } } + if len(res) == 0 { + return nil, agents.ErrNotFound + } return res, nil } +// deezerEmptyPicturePath is Deezer's empty-image-id path shape for artists with no picture +// (…/images/artist//1000x1000-…), which serves a generic silhouette on any CDN host. +const deezerEmptyPicturePath = "/images/artist//" + +func isPlaceholderPicture(url string) bool { + return strings.Contains(url, deezerEmptyPicturePath) +} + func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, error) { artists, err := s.client.searchArtists(ctx, name, deezerArtistSearchLimit) if errors.Is(err, ErrNotFound) || len(artists) == 0 { diff --git a/adapters/deezer/deezer_test.go b/adapters/deezer/deezer_test.go index f478af115..360db1f13 100644 --- a/adapters/deezer/deezer_test.go +++ b/adapters/deezer/deezer_test.go @@ -94,6 +94,54 @@ var _ = Describe("deezerAgent", func() { }) }) + Describe("GetArtistImages", func() { + var agent *deezerAgent + var httpClient *fakeHttpClient + + BeforeEach(func() { + httpClient = &fakeHttpClient{} + agent = &deezerAgent{ + dataStore: &tests.MockDataStore{}, + client: newClient(httpClient), + } + }) + + It("returns the real images when the artist has a picture", func() { + httpClient.mock("https://api.deezer.com/search/artist", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"data":[ + {"id":412,"name":"Queen","nb_fan":12744378, + "picture_xl":"https://cdn-images.dzcdn.net/images/artist/abc/1000x1000-000000-80-0-0.jpg", + "picture_big":"https://cdn-images.dzcdn.net/images/artist/abc/500x500-000000-80-0-0.jpg"} + ],"total":1}`)), + }) + + images, err := agent.GetArtistImages(ctx, "", "Queen", "") + + Expect(err).ToNot(HaveOccurred()) + Expect(images).To(HaveLen(2)) + Expect(images[0].URL).To(ContainSubstring("1000x1000")) + }) + + It("returns ErrNotFound when the artist only has empty-id placeholder pictures", func() { + httpClient.mock("https://api.deezer.com/search/artist", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"data":[ + {"id":412,"name":"Queen","nb_fan":12744378, + "picture_xl":"https://cdn-images.dzcdn.net/images/artist//1000x1000-000000-80-0-0.jpg", + "picture_big":"https://cdn-images.dzcdn.net/images/artist//500x500-000000-80-0-0.jpg", + "picture_medium":"https://cdn-images.dzcdn.net/images/artist//250x250-000000-80-0-0.jpg", + "picture_small":"https://cdn-images.dzcdn.net/images/artist//56x56-000000-80-0-0.jpg"} + ],"total":1}`)), + }) + + images, err := agent.GetArtistImages(ctx, "", "Queen", "") + + Expect(err).To(MatchError(agents.ErrNotFound)) + Expect(images).To(BeEmpty()) + }) + }) + Describe("GetArtistBiography - Language Fallback", func() { var agent *deezerAgent var httpClient *langAwareHttpClient From b7f94f67278d763003cedf8f662e3576c9622d79 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 19:28:11 -0400 Subject: [PATCH 53/87] feat(agents): enumerate enabled image-retriever agents per capability --- core/agents/agents.go | 44 +++++++++++++++++++++++ core/agents/agents_test.go | 72 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/core/agents/agents.go b/core/agents/agents.go index ead6dacd0..123eabb5c 100644 --- a/core/agents/agents.go +++ b/core/agents/agents.go @@ -124,6 +124,50 @@ func (a *Agents) AgentName() string { return "agents" } +// ArtistImageAgent pairs an enabled agent's name with its ArtistImageRetriever capability. +type ArtistImageAgent struct { + Name string + Retriever ArtistImageRetriever +} + +// AlbumImageAgent pairs an enabled agent's name with its AlbumImageRetriever capability. +type AlbumImageAgent struct { + Name string + Retriever AlbumImageRetriever +} + +// ArtistImageAgents returns the enabled agents implementing ArtistImageRetriever, +// in conf.Server.Agents order (same order the aggregate dispatch uses). +func (a *Agents) ArtistImageAgents() []ArtistImageAgent { + var result []ArtistImageAgent + for _, ea := range a.getEnabledAgentNames() { + ag := a.getAgent(ea) + if ag == nil { + continue + } + if retriever, ok := ag.(ArtistImageRetriever); ok { + result = append(result, ArtistImageAgent{Name: ea.name, Retriever: retriever}) + } + } + return result +} + +// AlbumImageAgents returns the enabled agents implementing AlbumImageRetriever, +// in conf.Server.Agents order (same order the aggregate dispatch uses). +func (a *Agents) AlbumImageAgents() []AlbumImageAgent { + var result []AlbumImageAgent + for _, ea := range a.getEnabledAgentNames() { + ag := a.getAgent(ea) + if ag == nil { + continue + } + if retriever, ok := ag.(AlbumImageRetriever); ok { + result = append(result, AlbumImageAgent{Name: ea.name, Retriever: retriever}) + } + } + return result +} + func (a *Agents) GetArtistMBID(ctx context.Context, id string, name string) (string, error) { switch id { case consts.UnknownArtistID: diff --git a/core/agents/agents_test.go b/core/agents/agents_test.go index 50285a084..e3087f995 100644 --- a/core/agents/agents_test.go +++ b/core/agents/agents_test.go @@ -362,6 +362,64 @@ var _ = Describe("Agents", func() { }) }) }) + + Describe("Image retriever enumeration", func() { + var ag *Agents + var artistImg, artistImg2 *testImageAgent + var albumImg, albumImg2 *testAlbumImageAgent + + BeforeEach(func() { + artistImg = &testImageAgent{Name: "artistImg"} + artistImg2 = &testImageAgent{Name: "artistImg2"} + albumImg = &testAlbumImageAgent{name: "albumImg"} + albumImg2 = &testAlbumImageAgent{name: "albumImg2"} + Register("artistImg", func(model.DataStore) Interface { return artistImg }) + Register("artistImg2", func(model.DataStore) Interface { return artistImg2 }) + Register("albumImg", func(model.DataStore) Interface { return albumImg }) + Register("albumImg2", func(model.DataStore) Interface { return albumImg2 }) + Register("noImages", func(model.DataStore) Interface { return &emptyAgent{} }) + }) + + Describe("ArtistImageAgents", func() { + It("returns only ArtistImageRetriever agents, named, in configured order", func() { + conf.Server.Agents = "artistImg,noImages,artistImg2" + ag = createAgents(ds, nil) + + result := ag.ArtistImageAgents() + Expect(result).To(HaveLen(2)) + Expect(result[0].Name).To(Equal("artistImg")) + Expect(result[0].Retriever).To(BeIdenticalTo(artistImg)) + Expect(result[1].Name).To(Equal("artistImg2")) + Expect(result[1].Retriever).To(BeIdenticalTo(artistImg2)) + }) + + It("is empty when external services are disabled", func() { + conf.Server.Agents = "" // what disableExternalServices() sets when EnableExternalServices=false + ag = createAgents(ds, nil) + Expect(ag.ArtistImageAgents()).To(BeEmpty()) + }) + }) + + Describe("AlbumImageAgents", func() { + It("returns only AlbumImageRetriever agents, named, in configured order", func() { + conf.Server.Agents = "albumImg,noImages,albumImg2" + ag = createAgents(ds, nil) + + result := ag.AlbumImageAgents() + Expect(result).To(HaveLen(2)) + Expect(result[0].Name).To(Equal("albumImg")) + Expect(result[0].Retriever).To(BeIdenticalTo(albumImg)) + Expect(result[1].Name).To(Equal("albumImg2")) + Expect(result[1].Retriever).To(BeIdenticalTo(albumImg2)) + }) + + It("is empty when external services are disabled", func() { + conf.Server.Agents = "" // what disableExternalServices() sets when EnableExternalServices=false + ag = createAgents(ds, nil) + Expect(ag.AlbumImageAgents()).To(BeEmpty()) + }) + }) + }) }) type mockAgent struct { @@ -497,3 +555,17 @@ func (t *testImageAgent) GetArtistImages(_ context.Context, id, name, mbid strin t.Args = []any{id, name, mbid} return t.Images, t.Err } + +type testAlbumImageAgent struct { + name string + Images []ExternalImage + Err error + Args []any +} + +func (t *testAlbumImageAgent) AgentName() string { return t.name } + +func (t *testAlbumImageAgent) GetAlbumImages(_ context.Context, name, artist, mbid string) ([]ExternalImage, error) { + t.Args = []any{name, artist, mbid} + return t.Images, t.Err +} From 190c291e6120ba55dbf6b6e26fa4ae6216e2e6d6 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 19:52:59 -0400 Subject: [PATCH 54/87] feat(artwork): worker fetches agent images directly with per-agent rate limits and breakers --- cmd/wire_gen.go | 4 +- core/artwork/agent_images.go | 97 ++++++++++++++++ core/artwork/agent_images_test.go | 180 +++++++++++++++++++++++++++++ core/artwork/processor.go | 16 +-- core/artwork/processor_test.go | 46 ++++++-- core/artwork/resolve.go | 50 ++++---- core/artwork/resolve_test.go | 137 ++++++++-------------- core/artwork/sources.go | 12 -- core/artwork/worker.go | 66 +++++++---- core/artwork/worker_soak_test.go | 5 +- core/artwork/worker_test.go | 60 ++++++---- core/artwork/worker_timing_test.go | 39 +++++++ 12 files changed, 516 insertions(+), 196 deletions(-) create mode 100644 core/artwork/agent_images.go create mode 100644 core/artwork/agent_images_test.go diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 44c4cb46d..6a500df3c 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -244,10 +244,8 @@ func CreateArtworkWorker() *artwork.Worker { metricsMetrics := metrics.GetPrometheusInstance(dataStore) manager := plugins.GetManager(dataStore, broker, metricsMetrics) agentsAgents := agents.GetAgents(dataStore, manager) - matcherMatcher := matcher.New(dataStore) - provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) fFmpeg := ffmpeg.New() - worker := artwork.NewWorker(dataStore, imageStore, provider, fFmpeg) + worker := artwork.NewWorker(dataStore, imageStore, agentsAgents, fFmpeg) return worker } diff --git a/core/artwork/agent_images.go b/core/artwork/agent_images.go new file mode 100644 index 000000000..659b1c8c2 --- /dev/null +++ b/core/artwork/agent_images.go @@ -0,0 +1,97 @@ +package artwork + +import ( + "context" + "errors" + "io" + "net/url" + + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/model" +) + +// gateFunc gates one named external fetch (rate limit + circuit breaker per name). +// resolveItem defaults to passthroughGate; the worker injects the per-agent gate. +type gateFunc = func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) + +func passthroughGate(_ string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { + return f() +} + +// bestImageURL returns the largest-Size image URL, skipping empty or unparseable +// URLs; nil when none qualifies. +func bestImageURL(imgs []agents.ExternalImage) *url.URL { + var best *agents.ExternalImage + for i := range imgs { + if imgs[i].URL == "" { + continue + } + if best == nil || imgs[i].Size > best.Size { + best = &imgs[i] + } + } + if best == nil { + return nil + } + u, err := url.Parse(best.URL) + if err != nil { + return nil + } + return u +} + +// fetchArtistImage tries each enabled artist-image agent in order, each under its own gate. +// Returns the winning reader + agent name; extErr is true only when NO agent succeeded and +// at least one failed transiently (a later success beats an earlier agent error). +func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar model.Artist) (r io.ReadCloser, agentName string, extErr bool) { + for _, a := range ag.ArtistImageAgents() { + reader, _, err := gate(a.Name, func() (io.ReadCloser, string, error) { + imgs, err := a.Retriever.GetArtistImages(ctx, ar.ID, ar.Name, ar.MbzArtistID) + if err != nil { + return nil, "", err + } + u := bestImageURL(imgs) + if u == nil { + return nil, "", agents.ErrNotFound + } + return fromURL(ctx, u) + }) + if reader != nil { + return reader, a.Name, false + } + if isTransientExternal(err) { + extErr = true // includes errBreakerOpen and download failures: retry via the next agent + } + } + return nil, "", extErr +} + +// fetchAlbumImage is the album counterpart of fetchArtistImage. +func fetchAlbumImage(ctx context.Context, ag *agents.Agents, gate gateFunc, al model.Album) (r io.ReadCloser, agentName string, extErr bool) { + for _, a := range ag.AlbumImageAgents() { + reader, _, err := gate(a.Name, func() (io.ReadCloser, string, error) { + imgs, err := a.Retriever.GetAlbumImages(ctx, al.Name, al.AlbumArtist, al.MbzAlbumID) + if err != nil { + return nil, "", err + } + u := bestImageURL(imgs) + if u == nil { + return nil, "", agents.ErrNotFound + } + return fromURL(ctx, u) + }) + if reader != nil { + return reader, a.Name, false + } + if isTransientExternal(err) { + extErr = true + } + } + return nil, "", extErr +} + +// isTransientExternal reports whether an external step failed in a way worth retrying; +// a not-found (from either package) is a definitive answer, not a fault. +func isTransientExternal(err error) bool { + return err != nil && !errors.Is(err, agents.ErrNotFound) && !errors.Is(err, model.ErrNotFound) +} diff --git a/core/artwork/agent_images_test.go b/core/artwork/agent_images_test.go new file mode 100644 index 000000000..f90863aed --- /dev/null +++ b/core/artwork/agent_images_test.go @@ -0,0 +1,180 @@ +package artwork + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// fakeImageAgent is a built-in agent stub implementing both image retrievers; it +// records call counts so per-agent ordering and short-circuiting can be asserted. +type fakeImageAgent struct { + name string + imgs []agents.ExternalImage + err error + artistCalls int + albumCalls int +} + +func (f *fakeImageAgent) AgentName() string { return f.name } + +func (f *fakeImageAgent) GetArtistImages(context.Context, string, string, string) ([]agents.ExternalImage, error) { + f.artistCalls++ + return f.imgs, f.err +} + +func (f *fakeImageAgent) GetAlbumImages(context.Context, string, string, string) ([]agents.ExternalImage, error) { + f.albumCalls++ + return f.imgs, f.err +} + +// imageAgents registers the fakes as built-in agents (ignoring the DataStore) and +// enables them in order, returning the process-wide Agents. Because the fakes ignore +// ds, reusing the GetAgents singleton across tests is safe. +func imageAgents(fakes ...*fakeImageAgent) *agents.Agents { + names := make([]string, 0, len(fakes)) + for _, f := range fakes { + fake := f + agents.Register(fake.name, func(model.DataStore) agents.Interface { return fake }) + names = append(names, fake.name) + } + conf.Server.Agents = strings.Join(names, ",") + return agents.GetAgents(&tests.MockDataStore{}, nil) +} + +var _ = Describe("agent images", func() { + var ( + ctx context.Context + srv *httptest.Server + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + ctx = context.Background() + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("image-bytes")) + })) + DeferCleanup(srv.Close) + }) + + img := func(path string, size int) agents.ExternalImage { + return agents.ExternalImage{URL: srv.URL + path, Size: size} + } + + Describe("bestImageURL", func() { + It("picks the largest-Size URL and skips empty ones", func() { + u := bestImageURL([]agents.ExternalImage{ + {URL: "http://x/small", Size: 10}, + {URL: "", Size: 9999}, + {URL: "http://x/big", Size: 100}, + }) + Expect(u).ToNot(BeNil()) + Expect(u.String()).To(Equal("http://x/big")) + }) + + It("returns nil when there is no non-empty URL", func() { + Expect(bestImageURL(nil)).To(BeNil()) + Expect(bestImageURL([]agents.ExternalImage{{URL: "", Size: 5}})).To(BeNil()) + }) + }) + + Describe("fetchArtistImage", func() { + It("returns the first agent's image and its name", func() { + a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}} + ag := imageAgents(a) + + r, name, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1", Name: "Artist"}) + Expect(r).ToNot(BeNil()) + defer r.Close() + Expect(name).To(Equal("agentA")) + Expect(extErr).To(BeFalse()) + }) + + It("falls through to a later agent, and its success beats the earlier error", func() { + a := &fakeImageAgent{name: "agentA", err: errBreakerOpen} + b := &fakeImageAgent{name: "agentB", imgs: []agents.ExternalImage{img("/b", 50)}} + ag := imageAgents(a, b) + + r, name, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"}) + Expect(r).ToNot(BeNil()) + defer r.Close() + Expect(name).To(Equal("agentB")) + Expect(extErr).To(BeFalse(), "a later hit clears an earlier agent's error") + Expect(a.artistCalls).To(Equal(1)) + Expect(b.artistCalls).To(Equal(1)) + }) + + It("reports a clean miss when every agent finds nothing", func() { + a := &fakeImageAgent{name: "agentA"} // no images, no error -> not found + b := &fakeImageAgent{name: "agentB", err: agents.ErrNotFound} + ag := imageAgents(a, b) + + r, name, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"}) + Expect(r).To(BeNil()) + Expect(name).To(BeEmpty()) + Expect(extErr).To(BeFalse(), "not-found is definitive, never a transient failure") + }) + + It("reports extErr when one agent fails transiently and the rest find nothing", func() { + a := &fakeImageAgent{name: "agentA", err: agents.ErrNotFound} + b := &fakeImageAgent{name: "agentB", err: context.DeadlineExceeded} + ag := imageAgents(a, b) + + r, _, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"}) + Expect(r).To(BeNil()) + Expect(extErr).To(BeTrue()) + }) + }) + + Describe("fetchAlbumImage", func() { + It("returns the winning agent's image and name", func() { + a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}} + ag := imageAgents(a) + + r, name, extErr := fetchAlbumImage(ctx, ag, passthroughGate, model.Album{Name: "Album", AlbumArtist: "Artist"}) + Expect(r).ToNot(BeNil()) + defer r.Close() + Expect(name).To(Equal("agentA")) + Expect(extErr).To(BeFalse()) + Expect(a.albumCalls).To(Equal(1)) + }) + + It("reports extErr when the only agent fails transiently", func() { + a := &fakeImageAgent{name: "agentA", err: context.DeadlineExceeded} + ag := imageAgents(a) + + r, _, extErr := fetchAlbumImage(ctx, ag, passthroughGate, model.Album{Name: "Album"}) + Expect(r).To(BeNil()) + Expect(extErr).To(BeTrue()) + }) + }) + + Describe("gate naming", func() { + It("invokes the gate once per agent, keyed by agent name", func() { + a := &fakeImageAgent{name: "agentA", err: context.DeadlineExceeded} + b := &fakeImageAgent{name: "agentB", imgs: []agents.ExternalImage{img("/b", 1)}} + ag := imageAgents(a, b) + + var gatedNames []string + gate := func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { + gatedNames = append(gatedNames, name) + return f() + } + + r, _, _ := fetchArtistImage(ctx, ag, gate, model.Artist{ID: "ar1"}) + Expect(r).ToNot(BeNil()) + defer r.Close() + Expect(gatedNames).To(Equal([]string{"agentA", "agentB"})) + }) + }) +}) diff --git a/core/artwork/processor.go b/core/artwork/processor.go index fefdebf84..28231b65e 100644 --- a/core/artwork/processor.go +++ b/core/artwork/processor.go @@ -10,8 +10,8 @@ import ( "io" "time" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/core/artwork/blurhash" - "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -42,14 +42,14 @@ const maxImageBytes = 20 << 20 // huge canvas that image.Decode would expand into gigabytes (decompression bomb). const maxImagePixels = 64 << 20 -// workerDeps are the collaborators processItem needs; extGate is set by NewWorker in +// workerDeps are the collaborators processItem needs; gate is set by NewWorker in // production and nil only in tests, where resolveItem falls back to a plain passthrough. type workerDeps struct { - ds model.DataStore - store *ImageStore - prov external.Provider - ffmpeg ffmpeg.FFmpeg - extGate extGateFunc + ds model.DataStore + store *ImageStore + agents *agents.Agents + ffmpeg ffmpeg.FFmpeg + gate gateFunc } // processItem resolves one queue item end to end: find an image, hash/decode/ @@ -57,7 +57,7 @@ type workerDeps struct { func processItem(ctx context.Context, deps *workerDeps, item model.ArtworkQueueItem) outcome { repo := deps.ds.Artwork(ctx) - res, err := resolveItem(ctx, deps.ds, deps.prov, deps.ffmpeg, item, deps.extGate) + res, err := resolveItem(ctx, deps.ds, deps.agents, deps.ffmpeg, item, deps.gate) if err != nil { log.Warn(ctx, "artwork: could not resolve item", "kind", item.ItemKind, "id", item.ItemID, err) return outcomeFailed diff --git a/core/artwork/processor_test.go b/core/artwork/processor_test.go index 9ea4338fe..bdb208562 100644 --- a/core/artwork/processor_test.go +++ b/core/artwork/processor_test.go @@ -5,13 +5,15 @@ import ( "encoding/binary" "errors" "hash/crc32" - "net/url" + "net/http" + "net/http/httptest" "os" "path/filepath" "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" @@ -40,7 +42,7 @@ var _ = Describe("processItem", func() { folderRepo *fakeFolderRepo libRepo *tests.MockLibraryRepo ffm *tests.MockFFmpeg - prov *fakeExternalProvider + ag *agents.Agents store *ImageStore artRepo *tests.MockArtworkRepo repoRoot string @@ -58,7 +60,7 @@ var _ = Describe("processItem", func() { libRepo = &tests.MockLibraryRepo{} libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) ffm = tests.NewMockFFmpeg("") - prov = &fakeExternalProvider{} + ag = agents.GetAgents(&tests.MockDataStore{}, nil) artRepo = tests.CreateMockArtworkRepo() ds = &tests.MockDataStore{ MockedFolder: folderRepo, @@ -67,7 +69,7 @@ var _ = Describe("processItem", func() { } ds.MockedAlbum = tests.CreateMockAlbumRepo() store = NewImageStore(GinkgoT().TempDir()) - deps = &workerDeps{ds: ds, store: store, prov: prov, ffmpeg: ffm} + deps = &workerDeps{ds: ds, store: store, agents: ag, ffmpeg: ffm} conf.Server.CoverArtPriority = "cover.jpg, embedded" }) @@ -141,9 +143,7 @@ var _ = Describe("processItem", func() { ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ {ID: "al4", Name: "Album"}, }) - prov.albumImage = func(context.Context, string) (*url.URL, error) { - return nil, errors.New("agent timed out") - } + imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) out := processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"}) Expect(out).To(Equal(outcomeFailed)) @@ -161,9 +161,7 @@ var _ = Describe("processItem", func() { ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ {ID: "alstale", Name: "Album", FolderIDs: []string{"f1"}}, }) - prov.albumImage = func(context.Context, string) (*url.URL, error) { - return nil, errors.New("agent timed out") - } + imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) out := processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"}) Expect(out).To(Equal(outcomeFoundStale)) @@ -174,6 +172,34 @@ var _ = Describe("processItem", func() { Expect(ia.Source).To(Equal("folder")) }) + It("found-external: persists source as external: and stores the fetched bytes", func() { + conf.Server.CoverArtPriority = "external" + imgBytes, err := os.ReadFile(filepath.Join(repoRoot, "tests/fixtures/artist/an-album/cover.jpg")) + Expect(err).ToNot(HaveOccurred()) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(imgBytes) + })) + DeferCleanup(srv.Close) + + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alext", Name: "Album"}}) + imageAgents(&fakeImageAgent{name: "deezerFake", imgs: []agents.ExternalImage{{URL: srv.URL, Size: 500}}}) + + out := processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alext"}) + Expect(out).To(Equal(outcomeFound)) + + ia, err := artRepo.GetItemArtwork("al", "alext", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("external:deezerFake")) + Expect(ia.Hash).ToNot(BeEmpty()) + + // External art is content-addressed into the store, not file-backed. + art, err := artRepo.GetImage(ia.Hash) + Expect(err).ToNot(HaveOccurred()) + rc, err := store.Open(ia.Hash, art.Mime) + Expect(err).ToNot(HaveOccurred()) + rc.Close() + }) + It("dedup: a second item with identical bytes skips decode and reuses the artwork row", func() { folderRepo.result = []model.Folder{{ Path: "tests/fixtures/artist/an-album", diff --git a/core/artwork/resolve.go b/core/artwork/resolve.go index 8a62b2999..6d41897e5 100644 --- a/core/artwork/resolve.go +++ b/core/artwork/resolve.go @@ -19,7 +19,7 @@ import ( "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/model" ) @@ -35,26 +35,18 @@ type resolution struct { extError bool } -// extGateFunc is an alias for the external-step wrapper the worker injects (rate -// limiter + circuit breaker); resolveItem defaults to a plain passthrough. -type extGateFunc = func(func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) - -func passthroughExtGate(f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { - return f() -} - // resolveItem walks the kind's priority chain and returns the first hit. -func resolveItem(ctx context.Context, ds model.DataStore, prov external.Provider, ffmpeg ffmpeg.FFmpeg, item model.ArtworkQueueItem, extGate extGateFunc) (resolution, error) { - if extGate == nil { - extGate = passthroughExtGate +func resolveItem(ctx context.Context, ds model.DataStore, ag *agents.Agents, ffmpeg ffmpeg.FFmpeg, item model.ArtworkQueueItem, gate gateFunc) (resolution, error) { + if gate == nil { + gate = passthroughGate } switch item.ItemKind { case "al": - return resolveAlbum(ctx, ds, prov, ffmpeg, item.ItemID, extGate) + return resolveAlbum(ctx, ds, ag, ffmpeg, item.ItemID, gate) case "ar": - return resolveArtist(ctx, ds, prov, ffmpeg, item.ItemID, extGate) + return resolveArtist(ctx, ds, ag, ffmpeg, item.ItemID, gate) case "pl": - return resolvePlaylist(ctx, ds, prov, ffmpeg, item.ItemID, extGate) + return resolvePlaylist(ctx, ds, ag, ffmpeg, item.ItemID, gate) case "ra": return resolveRadio(ctx, ds, item.ItemID) default: @@ -64,7 +56,7 @@ func resolveItem(ctx context.Context, ds model.DataStore, prov external.Provider // resolveAlbum ports the folder/embedded/external selection from // reader_album.go, walking conf.Server.CoverArtPriority. -func resolveAlbum(ctx context.Context, ds model.DataStore, prov external.Provider, ffm ffmpeg.FFmpeg, albumID string, extGate extGateFunc) (resolution, error) { +func resolveAlbum(ctx context.Context, ds model.DataStore, ag *agents.Agents, ffm ffmpeg.FFmpeg, albumID string, gate gateFunc) (resolution, error) { al, err := ds.Album(ctx).Get(albumID) if err != nil { return resolution{}, err @@ -88,8 +80,8 @@ func resolveAlbum(ctx context.Context, ds model.DataStore, prov external.Provide return res, nil } case pattern == "external": - if res, ok, isErr := resolveExternalStep(extGate, fromAlbumExternalSource(ctx, *al, prov)); ok { - return res, nil + if r, name, isErr := fetchAlbumImage(ctx, ag, gate, *al); r != nil { + return resolution{reader: r, source: "external:" + name}, nil } else if isErr { extErr = true } @@ -105,7 +97,7 @@ func resolveAlbum(ctx context.Context, ds model.DataStore, prov external.Provide // resolveArtist ports the upload/folder/external selection from // reader_artist.go: upload always wins, then conf.Server.ArtistArtPriority. -func resolveArtist(ctx context.Context, ds model.DataStore, prov external.Provider, ffm ffmpeg.FFmpeg, artistID string, extGate extGateFunc) (resolution, error) { +func resolveArtist(ctx context.Context, ds model.DataStore, ag *agents.Agents, ffm ffmpeg.FFmpeg, artistID string, gate gateFunc) (resolution, error) { ar, err := ds.Artist(ctx).Get(artistID) if err != nil { return resolution{}, err @@ -145,8 +137,8 @@ func resolveArtist(ctx context.Context, ds model.DataStore, prov external.Provid pattern = strings.TrimSpace(pattern) switch { case pattern == "external": - if res, ok, isErr := resolveExternalStep(extGate, fromArtistExternalResult(ctx, *ar, prov)); ok { - return res, nil + if r, name, isErr := fetchArtistImage(ctx, ag, gate, *ar); r != nil { + return resolution{reader: r, source: "external:" + name}, nil } else if isErr { extErr = true } @@ -178,7 +170,7 @@ func resolveArtist(ctx context.Context, ds model.DataStore, prov external.Provid // resolvePlaylist ports reader_playlist.go's chain: uploaded image, sidecar, // ExternalImageURL, then the generated 2x2 grid sourced through resolveAlbum. -func resolvePlaylist(ctx context.Context, ds model.DataStore, prov external.Provider, ffm ffmpeg.FFmpeg, playlistID string, extGate extGateFunc) (resolution, error) { +func resolvePlaylist(ctx context.Context, ds model.DataStore, ag *agents.Agents, ffm ffmpeg.FFmpeg, playlistID string, gate gateFunc) (resolution, error) { pl, err := ds.Playlist(ctx).Get(playlistID) if err != nil { return resolution{}, err @@ -191,7 +183,7 @@ func resolvePlaylist(ctx context.Context, ds model.DataStore, prov external.Prov if res, ok := resolveLocalFile(findPlaylistSidecarPath(ctx, pl.Path), "folder"); ok { return res, nil } - if res, ok, isErr := resolveExternalStep(extGate, fromPlaylistExternalSource(ctx, *pl)); ok { + if res, ok, isErr := resolveExternalStep(gate, "m3u", fromPlaylistExternalSource(ctx, *pl)); ok { return res, nil } else if isErr { extErr = true @@ -205,7 +197,7 @@ func resolvePlaylist(ctx context.Context, ds model.DataStore, prov external.Prov var tiles []image.Image var tileErr error // first internal (non-external) tile failure, e.g. album deleted mid-flight for _, albumID := range albumIDs { - res, err := resolveAlbum(ctx, ds, prov, ffm, albumID, extGate) + res, err := resolveAlbum(ctx, ds, ag, ffm, albumID, gate) if err != nil { if tileErr == nil { tileErr = err @@ -259,11 +251,11 @@ func resolveRadio(ctx context.Context, ds model.DataStore, radioID string) (reso return res, nil } -// resolveExternalStep runs an external sourceFunc through extGate, shared by -// resolveAlbum and resolveArtist. ok reports a hit; extErr reports a -// non-not-found error (a not-found is a definitive "no", not a failure). -func resolveExternalStep(extGate extGateFunc, sf func() (io.ReadCloser, string, error)) (res resolution, ok bool, extErr bool) { - r, path, err := extGate(sf) +// resolveExternalStep runs a single external sourceFunc through the named gate; used by +// the playlist ExternalImageURL step. ok reports a hit; extErr reports a non-not-found +// error (a not-found is a definitive "no", not a failure). +func resolveExternalStep(gate gateFunc, name string, sf sourceFunc) (res resolution, ok bool, extErr bool) { + r, path, err := gate(name, sf) if r != nil { return resolution{reader: r, source: "external", sourcePath: path}, true, false } diff --git a/core/artwork/resolve_test.go b/core/artwork/resolve_test.go index c64ee2b53..cce1554da 100644 --- a/core/artwork/resolve_test.go +++ b/core/artwork/resolve_test.go @@ -8,45 +8,18 @@ import ( "io" "net/http" "net/http/httptest" - "net/url" "os" "path/filepath" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) -// fakeExternalProvider is a minimal external.Provider stub for resolve_test.go; -// only AlbumImage/ArtistImage are exercised by the resolvers. -type fakeExternalProvider struct { - external.Provider - albumImage func(ctx context.Context, id string) (*url.URL, error) - artistImage func(ctx context.Context, id string) (*url.URL, error) -} - -func (f *fakeExternalProvider) AlbumImage(ctx context.Context, id string) (*url.URL, error) { - if f.albumImage != nil { - return f.albumImage(ctx, id) - } - return nil, model.ErrNotFound -} - -func (f *fakeExternalProvider) ArtistImage(ctx context.Context, id string) (*url.URL, error) { - if f.artistImage != nil { - return f.artistImage(ctx, id) - } - return nil, model.ErrNotFound -} - -func (f *fakeExternalProvider) ArtistImageResult(ctx context.Context, id string) (*url.URL, error) { - return f.ArtistImage(ctx, id) -} - var _ = Describe("resolveItem", func() { var ( ctx context.Context @@ -54,7 +27,7 @@ var _ = Describe("resolveItem", func() { folderRepo *fakeFolderRepo libRepo *tests.MockLibraryRepo ffm *tests.MockFFmpeg - prov *fakeExternalProvider + ag *agents.Agents repoRoot string ) @@ -69,7 +42,7 @@ var _ = Describe("resolveItem", func() { libRepo = &tests.MockLibraryRepo{} libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) ffm = tests.NewMockFFmpeg("") - prov = &fakeExternalProvider{} + ag = agents.GetAgents(&tests.MockDataStore{}, nil) ds = &tests.MockDataStore{ MockedFolder: folderRepo, MockedLibrary: libRepo, @@ -78,7 +51,7 @@ var _ = Describe("resolveItem", func() { Describe("kind dispatch", func() { It("returns an error for kinds the worker never enqueues", func() { - _, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "x"}, nil) + _, err := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "x"}, nil) Expect(err).To(HaveOccurred()) }) }) @@ -98,7 +71,7 @@ var _ = Describe("resolveItem", func() { {ID: "al1", Name: "Album", FolderIDs: []string{"f1"}}, }) - res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"}, nil) + res, err := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"}, nil) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).ToNot(BeNil()) defer res.reader.Close() @@ -114,7 +87,7 @@ var _ = Describe("resolveItem", func() { {ID: "al2", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}, }) - res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"}, nil) + res, err := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"}, nil) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).ToNot(BeNil()) defer res.reader.Close() @@ -128,11 +101,9 @@ var _ = Describe("resolveItem", func() { ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ {ID: "al3", Name: "Album"}, }) - prov.albumImage = func(context.Context, string) (*url.URL, error) { - return nil, errors.New("agent timed out") - } + imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) - res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"}, nil) + res, err := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"}, nil) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).To(BeNil()) Expect(res.extError).To(BeTrue()) @@ -143,9 +114,9 @@ var _ = Describe("resolveItem", func() { ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ {ID: "al4", Name: "Album"}, }) - // prov.albumImage left nil -> fakeExternalProvider returns model.ErrNotFound + // no image agents enabled -> the external step is a definitive not-found - res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"}, nil) + res, err := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"}, nil) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).To(BeNil()) Expect(res.extError).To(BeFalse()) @@ -160,11 +131,9 @@ var _ = Describe("resolveItem", func() { ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ {ID: "al6", Name: "Album", FolderIDs: []string{"f1"}}, }) - prov.albumImage = func(context.Context, string) (*url.URL, error) { - return nil, errors.New("agent timed out") - } + imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) - res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al6"}, nil) + res, err := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al6"}, nil) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).ToNot(BeNil()) defer res.reader.Close() @@ -181,9 +150,9 @@ var _ = Describe("resolveItem", func() { ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ {ID: "al7", Name: "Album", FolderIDs: []string{"f1"}}, }) - // prov.albumImage left nil -> fakeExternalProvider returns model.ErrNotFound + // no image agents enabled -> the external step is a definitive not-found - res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al7"}, nil) + res, err := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al7"}, nil) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).ToNot(BeNil()) defer res.reader.Close() @@ -191,24 +160,22 @@ var _ = Describe("resolveItem", func() { Expect(res.extError).To(BeFalse()) }) - It("routes the external step through a custom extGate", func() { + It("routes the external step through the injected gate, keyed by agent name", func() { conf.Server.CoverArtPriority = "external" ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ {ID: "al5", Name: "Album"}, }) - prov.albumImage = func(context.Context, string) (*url.URL, error) { - return nil, errors.New("boom") - } - var extGateCalls int - extGate := func(f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { - extGateCalls++ + imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("boom")}) + var gatedNames []string + gate := func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { + gatedNames = append(gatedNames, name) return f() } - res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al5"}, extGate) + res, err := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al5"}, gate) Expect(err).ToNot(HaveOccurred()) Expect(res.extError).To(BeTrue()) - Expect(extGateCalls).To(Equal(1)) + Expect(gatedNames).To(Equal([]string{"failAgent"})) }) }) @@ -224,7 +191,7 @@ var _ = Describe("resolveItem", func() { artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist", UploadedImage: "ar1_test.jpg"}}) ds.MockedArtist = artistRepo - res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar1"}, nil) + res, err := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar1"}, nil) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).ToNot(BeNil()) defer res.reader.Close() @@ -247,7 +214,7 @@ var _ = Describe("resolveItem", func() { {ID: "al9", Name: "Album", LibraryID: 0, FolderIDs: []string{"f1"}}, } - res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar2"}, nil) + res, err := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar2"}, nil) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).ToNot(BeNil()) defer res.reader.Close() @@ -260,11 +227,9 @@ var _ = Describe("resolveItem", func() { artistRepo := tests.CreateMockArtistRepo() artistRepo.SetData(model.Artists{{ID: "ar3", Name: "Artist"}}) ds.MockedArtist = artistRepo - prov.artistImage = func(context.Context, string) (*url.URL, error) { - return nil, errors.New("agent timed out") - } + imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) - res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar3"}, nil) + res, err := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar3"}, nil) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).To(BeNil()) Expect(res.extError).To(BeTrue()) @@ -275,32 +240,30 @@ var _ = Describe("resolveItem", func() { artistRepo := tests.CreateMockArtistRepo() artistRepo.SetData(model.Artists{{ID: "ar4", Name: "Artist"}}) ds.MockedArtist = artistRepo - // prov.artistImage left nil -> fakeExternalProvider returns model.ErrNotFound + // no image agents enabled -> the external step is a definitive not-found - res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar4"}, nil) + res, err := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar4"}, nil) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).To(BeNil()) Expect(res.extError).To(BeFalse()) }) - It("routes the external step through a custom extGate", func() { + It("routes the external step through the injected gate, keyed by agent name", func() { conf.Server.ArtistArtPriority = "external" artistRepo := tests.CreateMockArtistRepo() artistRepo.SetData(model.Artists{{ID: "ar5", Name: "Artist"}}) ds.MockedArtist = artistRepo - prov.artistImage = func(context.Context, string) (*url.URL, error) { - return nil, errors.New("boom") - } - var extGateCalls int - extGate := func(f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { - extGateCalls++ + imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("boom")}) + var gatedNames []string + gate := func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { + gatedNames = append(gatedNames, name) return f() } - res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar5"}, extGate) + res, err := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar5"}, gate) Expect(err).ToNot(HaveOccurred()) Expect(res.extError).To(BeTrue()) - Expect(extGateCalls).To(Equal(1)) + Expect(gatedNames).To(Equal([]string{"failAgent"})) }) }) @@ -313,7 +276,7 @@ var _ = Describe("resolveItem", func() { radioRepo.Data = map[string]*model.Radio{"ra1": {ID: "ra1", Name: "Radio"}} ds.MockedRadio = radioRepo - res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra1"}, nil) + res, err := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra1"}, nil) Expect(err).ToNot(HaveOccurred()) Expect(res).To(Equal(resolution{})) }) @@ -329,7 +292,7 @@ var _ = Describe("resolveItem", func() { radioRepo.Data = map[string]*model.Radio{"ra2": {ID: "ra2", Name: "Radio", UploadedImage: "ra2_test.jpg"}} ds.MockedRadio = radioRepo - res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra2"}, nil) + res, err := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra2"}, nil) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).ToNot(BeNil()) defer res.reader.Close() @@ -361,7 +324,7 @@ var _ = Describe("resolveItem", func() { plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: albumIDs} ds.MockedPlaylist = plRepo - res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl1"}, nil) + res, err := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl1"}, nil) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).ToNot(BeNil()) defer res.reader.Close() @@ -393,7 +356,7 @@ var _ = Describe("resolveItem", func() { plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1", "t2"}} ds.MockedPlaylist = plRepo - res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plu"}, nil) + res, err := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plu"}, nil) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).ToNot(BeNil()) defer res.reader.Close() @@ -411,7 +374,7 @@ var _ = Describe("resolveItem", func() { plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1", "t2"}} ds.MockedPlaylist = plRepo - res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pls"}, nil) + res, err := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pls"}, nil) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).ToNot(BeNil()) defer res.reader.Close() @@ -428,17 +391,17 @@ var _ = Describe("resolveItem", func() { plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} ds.MockedPlaylist = plRepo - var extGateCalls int - extGate := func(f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { - extGateCalls++ + var gatedNames []string + gate := func(name string, _ func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { + gatedNames = append(gatedNames, name) return nil, "", errors.New("network down") } - res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "ple"}, extGate) + res, err := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "ple"}, gate) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).To(BeNil()) Expect(res.extError).To(BeTrue()) - Expect(extGateCalls).To(Equal(1)) + Expect(gatedNames).To(Equal([]string{"m3u"}), "the playlist URL fetch is gated under \"m3u\"") }) It("treats a missing local ExternalImageURL as a definitive miss, not extError", func() { @@ -449,7 +412,7 @@ var _ = Describe("resolveItem", func() { plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} ds.MockedPlaylist = plRepo - res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plm"}, nil) + res, err := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plm"}, nil) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).To(BeNil()) Expect(res.extError).To(BeFalse()) @@ -467,7 +430,7 @@ var _ = Describe("resolveItem", func() { plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} ds.MockedPlaylist = plRepo - res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl404"}, nil) + res, err := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl404"}, nil) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).ToNot(BeNil()) defer res.reader.Close() @@ -488,7 +451,7 @@ var _ = Describe("resolveItem", func() { plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} ds.MockedPlaylist = plRepo - res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl500"}, nil) + res, err := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl500"}, nil) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).To(BeNil()) Expect(res.extError).To(BeTrue()) @@ -504,7 +467,7 @@ var _ = Describe("resolveItem", func() { ds.MockedPlaylist = plRepo folderRepo.result = nil - res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl2"}, nil) + res, err := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl2"}, nil) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).To(BeNil()) Expect(res.source).To(BeEmpty()) @@ -523,7 +486,7 @@ var _ = Describe("resolveItem", func() { plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} ds.MockedPlaylist = plRepo - res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plbomb"}, nil) + res, err := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plbomb"}, nil) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).To(BeNil()) Expect(res.source).To(BeEmpty()) @@ -537,7 +500,7 @@ var _ = Describe("resolveItem", func() { plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"missing1", "missing2"}} ds.MockedPlaylist = plRepo - res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl3"}, nil) + res, err := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl3"}, nil) Expect(err).To(HaveOccurred()) Expect(res).To(Equal(resolution{})) }) diff --git a/core/artwork/sources.go b/core/artwork/sources.go index d4ad0a2a7..04a9257fb 100644 --- a/core/artwork/sources.go +++ b/core/artwork/sources.go @@ -198,18 +198,6 @@ func fromArtistExternalSource(ctx context.Context, ar model.Artist, provider ext } } -// fromArtistExternalResult is the worker's artist external step: via ArtistImageResult a -// transient agent failure surfaces as an error (extError) rather than settling as absent. -func fromArtistExternalResult(ctx context.Context, ar model.Artist, provider external.Provider) sourceFunc { - return func() (io.ReadCloser, string, error) { - imageUrl, err := provider.ArtistImageResult(ctx, ar.ID) - if err != nil { - return nil, "", err - } - return fromURL(ctx, imageUrl) - } -} - func fromAlbumExternalSource(ctx context.Context, al model.Album, provider external.Provider) sourceFunc { return func() (io.ReadCloser, string, error) { imageUrl, err := provider.AlbumImage(ctx, al.ID) diff --git a/core/artwork/worker.go b/core/artwork/worker.go index 5ae40a6e7..a66734509 100644 --- a/core/artwork/worker.go +++ b/core/artwork/worker.go @@ -10,9 +10,9 @@ import ( "time" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core/external" - "github.com/navidrome/navidrome/core/ffmpeg" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "golang.org/x/time/rate" @@ -28,35 +28,38 @@ const ( var errBreakerOpen = errors.New("artwork: external circuit breaker open") -// Worker drains the artwork queue through processItem: the external step is rate-limited -// and circuit-broken, and prune is serialized against in-flight acquisitions via pruneMu. -type Worker struct { - deps workerDeps +// extGate is one agent's rate limiter + circuit breaker; each external agent gets its +// own so a provider whose API or CDN is down backs off in isolation from the others. +type extGate struct { limiter *rate.Limiter breaker *breaker +} + +// Worker drains the artwork queue through processItem: each external agent is rate-limited +// and circuit-broken independently, and prune is serialized against in-flight acquisitions +// via pruneMu. +type Worker struct { + deps workerDeps pruneMu sync.RWMutex wake chan struct{} runCtx context.Context + gatesMu sync.Mutex + gates map[string]*extGate + mu sync.Mutex inFlight map[string]struct{} } -func NewWorker(ds model.DataStore, store *ImageStore, prov external.Provider, ffmpeg ffmpeg.FFmpeg) *Worker { - rps := conf.Server.ArtworkExternalMaxRPS - limit := rate.Inf // 0 or negative disables the external throttle - if rps > 0 { - limit = rate.Limit(rps) - } +func NewWorker(ds model.DataStore, store *ImageStore, ag *agents.Agents, ffmpeg ffmpeg.FFmpeg) *Worker { w := &Worker{ - deps: workerDeps{ds: ds, store: store, prov: prov, ffmpeg: ffmpeg}, - limiter: rate.NewLimiter(limit, max(1, rps)), - breaker: newBreaker(), + deps: workerDeps{ds: ds, store: store, agents: ag, ffmpeg: ffmpeg}, wake: make(chan struct{}, 1), runCtx: context.Background(), + gates: map[string]*extGate{}, inFlight: map[string]struct{}{}, } - w.deps.extGate = w.gate + w.deps.gate = w.gate return w } @@ -195,20 +198,39 @@ func queueKey(it model.ArtworkQueueItem) string { return it.ItemKind + "|" + it.ItemID + "|" + it.ImageType } -// gate wraps the external step with the rate limiter and circuit breaker, matching -// extGateFunc so it can be injected via workerDeps.extGate. -func (w *Worker) gate(f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { - if !w.breaker.allow() { +// gate wraps a named external step with that agent's own rate limiter and circuit +// breaker, matching gateFunc so it can be injected via workerDeps.gate. +func (w *Worker) gate(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { + g := w.gateFor(name) + if !g.breaker.allow() { return nil, "", errBreakerOpen } - if err := w.limiter.Wait(w.runCtx); err != nil { + if err := g.limiter.Wait(w.runCtx); err != nil { return nil, "", err } r, path, err := f() - w.breaker.record(err) + g.breaker.record(err) return r, path, err } +// gateFor lazily creates the per-name gate on first use, each with its own limiter at +// ArtworkExternalMaxRPS and its own breaker. +func (w *Worker) gateFor(name string) *extGate { + w.gatesMu.Lock() + defer w.gatesMu.Unlock() + if g, ok := w.gates[name]; ok { + return g + } + rps := conf.Server.ArtworkExternalMaxRPS + limit := rate.Inf + if rps > 0 { + limit = rate.Limit(rps) + } + g := &extGate{limiter: rate.NewLimiter(limit, max(1, rps)), breaker: newBreaker()} + w.gates[name] = g + return g +} + // backoffFor returns min(5m×4^n, 48h) scaled by (1+jitter), with jitter in [-0.2, 0.2]. func backoffFor(attempts int, jitter float64) time.Duration { d := math.Min(float64(backoffBase)*math.Pow(4, float64(attempts)), float64(backoffCap)) diff --git a/core/artwork/worker_soak_test.go b/core/artwork/worker_soak_test.go index 355ecb7ca..59a042106 100644 --- a/core/artwork/worker_soak_test.go +++ b/core/artwork/worker_soak_test.go @@ -10,6 +10,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" @@ -39,7 +40,7 @@ var _ = Describe("Worker soak", func() { ImageFiles: []string{"cover.jpg"}, }}} ffm := tests.NewMockFFmpeg("") - prov := &fakeExternalProvider{} + ag := agents.GetAgents(&tests.MockDataStore{}, nil) artRepo := tests.CreateMockArtworkRepo() albumRepo := tests.CreateMockAlbumRepo() albumRepo.SetData(model.Albums{ @@ -53,7 +54,7 @@ var _ = Describe("Worker soak", func() { MockedAlbum: albumRepo, } store := NewImageStore(GinkgoT().TempDir()) - deps := &workerDeps{ds: ds, store: store, prov: prov, ffmpeg: ffm} + deps := &workerDeps{ds: ds, store: store, agents: ag, ffmpeg: ffm} conf.Server.CoverArtPriority = "cover.jpg, embedded" // Dangling refs (al/ra ids the repos don't know about) mirror an entity diff --git a/core/artwork/worker_test.go b/core/artwork/worker_test.go index e1b0c985d..7a072aa1e 100644 --- a/core/artwork/worker_test.go +++ b/core/artwork/worker_test.go @@ -4,12 +4,12 @@ import ( "context" "errors" "io" - "net/url" "os" "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" @@ -54,7 +54,7 @@ var _ = Describe("Worker", func() { folderRepo *fakeFolderRepo libRepo *tests.MockLibraryRepo ffm *tests.MockFFmpeg - prov *fakeExternalProvider + ag *agents.Agents store *ImageStore artRepo *tests.MockArtworkRepo queueRepo *tests.MockArtworkQueueRepo @@ -73,7 +73,7 @@ var _ = Describe("Worker", func() { libRepo = &tests.MockLibraryRepo{} libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) ffm = tests.NewMockFFmpeg("") - prov = &fakeExternalProvider{} + ag = agents.GetAgents(&tests.MockDataStore{}, nil) artRepo = tests.CreateMockArtworkRepo() queueRepo = tests.CreateMockArtworkQueueRepo() ds = &tests.MockDataStore{ @@ -86,7 +86,7 @@ var _ = Describe("Worker", func() { store = NewImageStore(GinkgoT().TempDir()) conf.Server.CoverArtPriority = "cover.jpg, embedded" conf.Server.ArtworkExternalMaxRPS = 1000 // keep the limiter out of the way of behavior tests - w = NewWorker(ds, store, prov, ffm) + w = NewWorker(ds, store, ag, ffm) }) Describe("drain", func() { @@ -118,9 +118,7 @@ var _ = Describe("Worker", func() { It("reschedules a failed item via MarkFailed with a backed-off retry_at", func() { conf.Server.CoverArtPriority = "external" ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al4", Name: "Album"}}) - prov.albumImage = func(context.Context, string) (*url.URL, error) { - return nil, errors.New("agent timed out") - } + imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"})).To(Succeed()) n, err := w.drain(ctx, 2) @@ -145,9 +143,7 @@ var _ = Describe("Worker", func() { ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ {ID: "alstale", Name: "Album", FolderIDs: []string{"f1"}}, }) - prov.albumImage = func(context.Context, string) (*url.URL, error) { - return nil, errors.New("agent timed out") - } + imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"})).To(Succeed()) n, err := w.drain(ctx, 2) @@ -174,7 +170,7 @@ var _ = Describe("Worker", func() { }) racing := &reenqueueOnDequeue{MockArtworkQueueRepo: queueRepo} ds.MockedArtworkQueue = racing - w = NewWorker(ds, store, prov, ffm) + w = NewWorker(ds, store, ag, ffm) Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ ItemKind: "al", ItemID: "al7", Priority: model.ArtworkPriorityScan, })).To(Succeed()) @@ -193,12 +189,10 @@ var _ = Describe("Worker", func() { It("keeps a fresh re-enqueue ahead of a stale failure backoff", func() { conf.Server.CoverArtPriority = "external" ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al8", Name: "Album"}}) - prov.albumImage = func(context.Context, string) (*url.URL, error) { - return nil, errors.New("agent timed out") - } + imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) racing := &reenqueueOnDequeue{MockArtworkQueueRepo: queueRepo} ds.MockedArtworkQueue = racing - w = NewWorker(ds, store, prov, ffm) + w = NewWorker(ds, store, ag, ffm) Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al8"})).To(Succeed()) dequeued := findQueued(queueRepo, "al", "al8").RetryAt @@ -221,7 +215,7 @@ var _ = Describe("Worker", func() { private: model.Playlist{ID: "plPriv", OwnerID: "admin"}, tracks: &tests.MockPlaylistTrackRepo{}, } - w = NewWorker(vds, store, prov, ffm) + w = NewWorker(vds, store, ag, ffm) Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plPriv"})).To(Succeed()) n, err := w.drain(ctx, 1) @@ -259,13 +253,13 @@ var _ = Describe("Worker", func() { return nil, "", errors.New("boom") } for range 5 { - _, _, err := w.gate(failing) + _, _, err := w.gate("A", failing) Expect(err).To(HaveOccurred()) } Expect(calls).To(Equal(5)) - _, _, err := w.gate(failing) - Expect(err).To(HaveOccurred()) + _, _, err := w.gate("A", failing) + Expect(err).To(MatchError(errBreakerOpen)) Expect(calls).To(Equal(5), "an open breaker must not call the external step") }) @@ -273,9 +267,9 @@ var _ = Describe("Worker", func() { failing := func() (io.ReadCloser, string, error) { return nil, "", errors.New("boom") } ok := func() (io.ReadCloser, string, error) { return io.NopCloser(nil), "p", nil } for range 4 { - _, _, _ = w.gate(failing) + _, _, _ = w.gate("A", failing) } - _, _, err := w.gate(ok) + _, _, err := w.gate("A", ok) Expect(err).ToNot(HaveOccurred()) var calls int @@ -284,10 +278,30 @@ var _ = Describe("Worker", func() { return nil, "", errors.New("boom") } for range 5 { - _, _, _ = w.gate(counting) + _, _, _ = w.gate("A", counting) } Expect(calls).To(Equal(5), "the breaker should have re-closed after the success") }) + + It("isolates each agent's breaker: one open gate does not block another", func() { + failing := func() (io.ReadCloser, string, error) { return nil, "", errors.New("boom") } + for range breakerThreshold { + _, _, _ = w.gate("A", failing) + } + _, _, err := w.gate("A", failing) + Expect(err).To(MatchError(errBreakerOpen), "agent A's breaker is open") + + var bCalls int + bStep := func() (io.ReadCloser, string, error) { + bCalls++ + return io.NopCloser(nil), "p", nil + } + for range breakerThreshold + 2 { + _, _, err := w.gate("B", bStep) + Expect(err).ToNot(HaveOccurred(), "agent B keeps being called while A is open") + } + Expect(bCalls).To(Equal(breakerThreshold + 2)) + }) }) Describe("RunPrune", func() { @@ -313,7 +327,7 @@ var _ = Describe("Worker", func() { DeferCleanup(func() { goleak.VerifyNone(GinkgoT(), ignore) }) localDS := &tests.MockDataStore{MockedArtworkQueue: tests.CreateMockArtworkQueueRepo()} - lw := NewWorker(localDS, NewImageStore(GinkgoT().TempDir()), &fakeExternalProvider{}, tests.NewMockFFmpeg("")) + lw := NewWorker(localDS, NewImageStore(GinkgoT().TempDir()), agents.GetAgents(localDS, nil), tests.NewMockFFmpeg("")) runCtx, cancel := context.WithCancel(ctx) done := make(chan error, 1) diff --git a/core/artwork/worker_timing_test.go b/core/artwork/worker_timing_test.go index 4bb14df3a..30c815c50 100644 --- a/core/artwork/worker_timing_test.go +++ b/core/artwork/worker_timing_test.go @@ -2,10 +2,13 @@ package artwork import ( "errors" + "io" "testing" "testing/synctest" "time" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/tests" . "github.com/onsi/gomega" ) @@ -37,3 +40,39 @@ func TestArtworkBreakerHalfOpen(t *testing.T) { g.Expect(b.allow()).To(BeTrue()) }) } + +// Drives the worker's per-name gate map with the fake clock: one agent's open breaker +// must neither block another agent nor short-circuit the other's probe recovery. +func TestArtworkGatePerAgentBreakerIsolation(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + g := NewWithT(t) + w := NewWorker(&tests.MockDataStore{}, NewImageStore(t.TempDir()), + agents.GetAgents(&tests.MockDataStore{}, nil), tests.NewMockFFmpeg("")) + + fail := func() (io.ReadCloser, string, error) { return nil, "", errors.New("boom") } + for range breakerThreshold { + _, _, _ = w.gate("A", fail) + } + _, _, err := w.gate("A", fail) + g.Expect(err).To(MatchError(errBreakerOpen), "A opens after consecutive errors") + + // B has its own breaker, untouched by A being open. + var bCalls int + bStep := func() (io.ReadCloser, string, error) { bCalls++; return nil, "", errors.New("boom") } + for range breakerThreshold - 1 { + _, _, err := w.gate("B", bStep) + g.Expect(err).To(MatchError("boom")) + } + g.Expect(bCalls).To(Equal(breakerThreshold-1), "B keeps being called while A is open") + + // After the probe window, A admits exactly one probe again. + time.Sleep(breakerProbeAfter) + var aCalls int + aFail := func() (io.ReadCloser, string, error) { aCalls++; return nil, "", errors.New("boom") } + _, _, _ = w.gate("A", aFail) + g.Expect(aCalls).To(Equal(1), "A grants a single probe after the interval") + _, _, err = w.gate("A", aFail) + g.Expect(err).To(MatchError(errBreakerOpen), "the probe failed, so A stays open") + g.Expect(aCalls).To(Equal(1)) + }) +} From 39e939686b0265aff9826737703ac193017271fd Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 19:58:57 -0400 Subject: [PATCH 55/87] fix(artwork): treat agent not-found as breaker success --- core/artwork/worker.go | 5 +++-- core/artwork/worker_test.go | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/core/artwork/worker.go b/core/artwork/worker.go index a66734509..9365cf50c 100644 --- a/core/artwork/worker.go +++ b/core/artwork/worker.go @@ -267,8 +267,9 @@ func (b *breaker) allow() bool { func (b *breaker) record(err error) { b.mu.Lock() defer b.mu.Unlock() - // A not-found is a definitive answer, not a fault; only real errors trip the breaker. - if err == nil || errors.Is(err, model.ErrNotFound) { + // A not-found (from either package) is a definitive answer, not a fault; only real + // errors trip the breaker. Must stay consistent with isTransientExternal. + if err == nil || errors.Is(err, model.ErrNotFound) || errors.Is(err, agents.ErrNotFound) { b.failures = 0 return } diff --git a/core/artwork/worker_test.go b/core/artwork/worker_test.go index 7a072aa1e..cf9b7cddf 100644 --- a/core/artwork/worker_test.go +++ b/core/artwork/worker_test.go @@ -283,6 +283,25 @@ var _ = Describe("Worker", func() { Expect(calls).To(Equal(5), "the breaker should have re-closed after the success") }) + It("does not open the breaker on a run of agent not-found misses", func() { + // Regression: agents.ErrNotFound is a definitive miss, not a fault. A run of + // artless items must never trip the breaker, or they'd loop in retry instead of + // settling absent. Uses the real gate, not passthroughGate. + notFound := func() (io.ReadCloser, string, error) { return nil, "", agents.ErrNotFound } + for range breakerThreshold + 3 { + _, _, err := w.gate("A", notFound) + Expect(err).To(MatchError(agents.ErrNotFound), "a miss passes through, never errBreakerOpen") + } + + var calls int + counting := func() (io.ReadCloser, string, error) { + calls++ + return nil, "", errors.New("boom") + } + _, _, _ = w.gate("A", counting) + Expect(calls).To(Equal(1), "the breaker stayed closed, so the step still runs") + }) + It("isolates each agent's breaker: one open gate does not block another", func() { failing := func() (io.ReadCloser, string, error) { return nil, "", errors.New("boom") } for range breakerThreshold { From 3b7cbf41dd7f4c1b735c483afe1df83a0494b2a2 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 20:06:13 -0400 Subject: [PATCH 56/87] feat(model): content-hash artwork id suffix and hydratable per-entity image state --- core/artwork/artwork_internal_test.go | 8 +-- model/album.go | 1 + model/artist.go | 1 + model/artwork.go | 7 +++ model/artwork_id.go | 70 +++++++++++++-------------- model/artwork_id_test.go | 63 +++++++++++++++++++++--- model/mediafile.go | 7 ++- model/playlist.go | 1 + model/radio.go | 2 + model/radio_test.go | 5 +- server/subsonic/radio_test.go | 4 +- 11 files changed, 115 insertions(+), 54 deletions(-) diff --git a/core/artwork/artwork_internal_test.go b/core/artwork/artwork_internal_test.go index c95371959..f5312361d 100644 --- a/core/artwork/artwork_internal_test.go +++ b/core/artwork/artwork_internal_test.go @@ -281,14 +281,14 @@ var _ = Describe("Artwork", func() { Expect(err).ToNot(HaveOccurred()) _, path, err := aw.Reader(ctx) Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal("al-444_0")) + Expect(path).To(Equal("al-444")) }) It("returns album cover if media file has no cover art", func() { aw, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-"+mfWithoutEmbed.ID)) Expect(err).ToNot(HaveOccurred()) _, path, err := aw.Reader(ctx) Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal("al-444_0")) + Expect(path).To(Equal("al-444")) }) It("falls back to disc cover art when media file has a disc number on a multi-disc album", func() { mfWithDisc := model.MediaFile{ID: "46", Path: "tests/fixtures/test.ogg", AlbumID: "444", DiscNumber: 2} @@ -299,7 +299,7 @@ var _ = Describe("Artwork", func() { _, path, err := aw.Reader(ctx) Expect(err).ToNot(HaveOccurred()) // Should fall back to disc art, which itself falls back to album art - Expect(path).To(Equal("dc-444:2_0")) + Expect(path).To(Equal("dc-444:2")) }) It("falls back to album cover art for single-disc albums even with a disc number", func() { mfOnSingleDisc := model.MediaFile{ID: "47", Path: "tests/fixtures/test.ogg", AlbumID: "888", DiscNumber: 1} @@ -310,7 +310,7 @@ var _ = Describe("Artwork", func() { _, path, err := aw.Reader(ctx) Expect(err).ToNot(HaveOccurred()) // Single-disc album should skip disc art and go straight to album art - Expect(path).To(Equal("al-888_0")) + Expect(path).To(Equal("al-888")) }) }) }) diff --git a/model/album.go b/model/album.go index 7c03ccf1e..05d907a28 100644 --- a/model/album.go +++ b/model/album.go @@ -13,6 +13,7 @@ import ( type Album struct { Annotations `structs:"-" hash:"ignore"` + ItemImage `structs:"-" json:"-" hash:"ignore"` ID string `structs:"id" json:"id"` LibraryID int `structs:"library_id" json:"libraryId"` diff --git a/model/artist.go b/model/artist.go index 053f261ce..6e0f073ef 100644 --- a/model/artist.go +++ b/model/artist.go @@ -11,6 +11,7 @@ import ( type Artist struct { Annotations `structs:"-"` + ItemImage `structs:"-" json:"-"` ID string `structs:"id" json:"id"` diff --git a/model/artwork.go b/model/artwork.go index 8db11ce69..a4f9e01e1 100644 --- a/model/artwork.go +++ b/model/artwork.go @@ -15,6 +15,13 @@ type Artwork struct { const ImageTypePrimary = "primary" +// ItemImage is per-entity artwork state hydrated at query time; never persisted +// (structs:"-" keeps it out of upserts) nor exposed via the native API (json:"-"). +type ItemImage struct { + ImageHash string `structs:"-" json:"-"` + ImageAbsent bool `structs:"-" json:"-"` +} + // ItemArtwork is an entity's resolved artwork state. Hash=="" means known absent. type ItemArtwork struct { ItemKind string `structs:"item_kind"` diff --git a/model/artwork_id.go b/model/artwork_id.go index 1bd146c1f..15db720e5 100644 --- a/model/artwork_id.go +++ b/model/artwork_id.go @@ -38,7 +38,8 @@ var artworkKindMap = map[string]Kind{ type ArtworkID struct { Kind Kind ID string - LastUpdate time.Time + Hash string // content-hash suffix; "" = unknown/none + LastUpdate time.Time // legacy: populated only when parsing old _ tokens } func (id ArtworkID) String() string { @@ -46,14 +47,14 @@ func (id ArtworkID) String() string { return "" } s := fmt.Sprintf("%s-%s", id.Kind.prefix, id.ID) - if lu := id.LastUpdate.Unix(); lu > 0 { - return fmt.Sprintf("%s_%x", s, lu) + if id.Hash != "" { + return s + "_" + id.Hash } - return s + "_0" + return s } func NewArtworkID(kind Kind, id string, lastUpdate *time.Time) ArtworkID { - artID := ArtworkID{kind, id, time.Time{}} + artID := ArtworkID{Kind: kind, ID: id} if lastUpdate != nil { artID.LastUpdate = *lastUpdate } @@ -75,18 +76,34 @@ func ParseArtworkID(id string) (ArtworkID, error) { } parts = strings.SplitN(parts[1], "_", 2) if len(parts) == 2 { - if parts[1] != "0" { - lastUpdate, err := strconv.ParseInt(parts[1], 16, 64) - if err != nil { - return ArtworkID{}, err - } - parsedID.LastUpdate = time.Unix(lastUpdate, 0) - } parsedID.ID = parts[0] + suffix := parts[1] + switch { + // Hash detection must come first: a 16-hex value with the high bit set overflows int64. + case isImageHash(suffix): + parsedID.Hash = suffix + case suffix != "0": + if lastUpdate, err := strconv.ParseInt(suffix, 16, 64); err == nil { + parsedID.LastUpdate = time.Unix(lastUpdate, 0) + } + } } return parsedID, nil } +// isImageHash reports whether s is a 16-char lowercase-hex XXH3-64 content hash. +func isImageHash(s string) bool { + if len(s) != 16 { + return false + } + for _, c := range s { + if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') { + return false + } + } + return true +} + func MustParseArtworkID(id string) ArtworkID { artID, err := ParseArtworkID(id) if err != nil { @@ -112,40 +129,21 @@ func ParseDiscArtworkID(id string) (albumID string, discNumber int, err error) { } func artworkIDFromAlbum(al Album) ArtworkID { - return ArtworkID{ - Kind: KindAlbumArtwork, - ID: al.ID, - LastUpdate: al.UpdatedAt, - } + return ArtworkID{Kind: KindAlbumArtwork, ID: al.ID, Hash: al.ImageHash} } func artworkIDFromMediaFile(mf MediaFile) ArtworkID { - return ArtworkID{ - Kind: KindMediaFileArtwork, - ID: mf.ID, - LastUpdate: mf.UpdatedAt, - } + return ArtworkID{Kind: KindMediaFileArtwork, ID: mf.ID, Hash: mf.ImageHash} } func artworkIDFromPlaylist(pls Playlist) ArtworkID { - return ArtworkID{ - Kind: KindPlaylistArtwork, - ID: pls.ID, - LastUpdate: pls.UpdatedAt, - } + return ArtworkID{Kind: KindPlaylistArtwork, ID: pls.ID, Hash: pls.ImageHash} } func artworkIDFromArtist(ar Artist) ArtworkID { - return ArtworkID{ - Kind: KindArtistArtwork, - ID: ar.ID, - } + return ArtworkID{Kind: KindArtistArtwork, ID: ar.ID, Hash: ar.ImageHash} } func artworkIDFromRadio(r Radio) ArtworkID { - return ArtworkID{ - Kind: KindRadioArtwork, - ID: r.ID, - LastUpdate: r.UpdatedAt, - } + return ArtworkID{Kind: KindRadioArtwork, ID: r.ID, Hash: r.ImageHash} } diff --git a/model/artwork_id_test.go b/model/artwork_id_test.go index ad66f7bb5..af6a12ffb 100644 --- a/model/artwork_id_test.go +++ b/model/artwork_id_test.go @@ -9,14 +9,31 @@ import ( ) var _ = Describe("ArtworkID", func() { + Describe("String()", func() { + It("returns a bare id when there is no hash", func() { + id := model.ArtworkID{Kind: model.KindAlbumArtwork, ID: "1234"} + Expect(id.String()).To(Equal("al-1234")) + }) + It("appends the hash suffix when set", func() { + id := model.ArtworkID{Kind: model.KindAlbumArtwork, ID: "1234", Hash: "abcdef0123456789"} + Expect(id.String()).To(Equal("al-1234_abcdef0123456789")) + }) + It("never emits a legacy timestamp/_0 suffix", func() { + id := model.NewArtworkID(model.KindAlbumArtwork, "1234", new(time.Now())) + Expect(id.String()).To(Equal("al-1234")) + }) + It("returns empty string for an empty id", func() { + Expect(model.ArtworkID{Kind: model.KindAlbumArtwork}.String()).To(BeEmpty()) + }) + }) + Describe("NewArtworkID()", func() { - It("creates a valid parseable ArtworkID", func() { + It("round-trips Kind and ID through String()", func() { id := model.NewArtworkID(model.KindAlbumArtwork, "1234", new(time.Now())) parsedId, err := model.ParseArtworkID(id.String()) Expect(err).ToNot(HaveOccurred()) Expect(parsedId.Kind).To(Equal(id.Kind)) Expect(parsedId.ID).To(Equal(id.ID)) - Expect(parsedId.LastUpdate.Unix()).To(Equal(id.LastUpdate.Unix())) }) It("creates a valid ArtworkID without lastUpdate info", func() { id := model.NewArtworkID(model.KindPlaylistArtwork, "1234", nil) @@ -24,18 +41,16 @@ var _ = Describe("ArtworkID", func() { Expect(err).ToNot(HaveOccurred()) Expect(parsedId.Kind).To(Equal(id.Kind)) Expect(parsedId.ID).To(Equal(id.ID)) - Expect(parsedId.LastUpdate.Unix()).To(Equal(id.LastUpdate.Unix())) }) }) + Describe("ParseArtworkID - disc kind", func() { It("parses a disc artwork ID with dc prefix", func() { - now := time.Now() - id := model.NewArtworkID(model.KindDiscArtwork, "albumid123:2", &now) + id := model.NewArtworkID(model.KindDiscArtwork, "albumid123:2", nil) parsedId, err := model.ParseArtworkID(id.String()) Expect(err).ToNot(HaveOccurred()) Expect(parsedId.Kind).To(Equal(model.KindDiscArtwork)) Expect(parsedId.ID).To(Equal("albumid123:2")) - Expect(parsedId.LastUpdate.Unix()).To(Equal(now.Unix())) }) }) @@ -67,6 +82,7 @@ var _ = Describe("ArtworkID", func() { Expect(err).ToNot(HaveOccurred()) Expect(id.Kind).To(Equal(model.KindAlbumArtwork)) Expect(id.ID).To(Equal("1234")) + Expect(id.Hash).To(BeEmpty()) }) It("parses media file artwork ids", func() { id, err := model.ParseArtworkID("mf-a6f8d2b1") @@ -74,12 +90,45 @@ var _ = Describe("ArtworkID", func() { Expect(id.Kind).To(Equal(model.KindMediaFileArtwork)) Expect(id.ID).To(Equal("a6f8d2b1")) }) - It("parses playlists artwork ids", func() { + It("parses playlist artwork ids with dashed UUID", func() { id, err := model.ParseArtworkID("pl-18690de0-151b-4d86-81cb-f418a907315a") Expect(err).ToNot(HaveOccurred()) Expect(id.Kind).To(Equal(model.KindPlaylistArtwork)) Expect(id.ID).To(Equal("18690de0-151b-4d86-81cb-f418a907315a")) }) + It("captures a 16-hex suffix as Hash", func() { + id, err := model.ParseArtworkID("al-1234_abcdef0123456789") + Expect(err).ToNot(HaveOccurred()) + Expect(id.ID).To(Equal("1234")) + Expect(id.Hash).To(Equal("abcdef0123456789")) + Expect(id.LastUpdate.IsZero()).To(BeTrue()) + }) + It("captures a high-bit 16-hex suffix as Hash without error", func() { + id, err := model.ParseArtworkID("al-1234_ffffffffffffffff") + Expect(err).ToNot(HaveOccurred()) + Expect(id.ID).To(Equal("1234")) + Expect(id.Hash).To(Equal("ffffffffffffffff")) + }) + It("parses a legacy hex-timestamp suffix as LastUpdate", func() { + id, err := model.ParseArtworkID("al-123_688a1b2c") + Expect(err).ToNot(HaveOccurred()) + Expect(id.ID).To(Equal("123")) + Expect(id.Hash).To(BeEmpty()) + Expect(id.LastUpdate.Unix()).To(Equal(int64(0x688a1b2c))) + }) + It("parses a legacy _0 suffix", func() { + id, err := model.ParseArtworkID("al-123_0") + Expect(err).ToNot(HaveOccurred()) + Expect(id.ID).To(Equal("123")) + Expect(id.Hash).To(BeEmpty()) + Expect(id.LastUpdate.IsZero()).To(BeTrue()) + }) + It("silently drops a garbage suffix", func() { + id, err := model.ParseArtworkID("al-123_zz") + Expect(err).ToNot(HaveOccurred()) + Expect(id.ID).To(Equal("123")) + Expect(id.Hash).To(BeEmpty()) + }) It("fails to parse malformed ids", func() { _, err := model.ParseArtworkID("a6f8d2b1") Expect(err).To(MatchError("invalid artwork id")) diff --git a/model/mediafile.go b/model/mediafile.go index 22ab7fbbe..7c56f43df 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -24,6 +24,7 @@ import ( type MediaFile struct { Annotations `structs:"-" hash:"ignore"` Bookmarkable `structs:"-" hash:"ignore"` + ItemImage `structs:"-" json:"-" hash:"ignore"` ID string `structs:"id" json:"id" hash:"ignore"` PID string `structs:"pid" json:"-" hash:"ignore"` @@ -139,13 +140,15 @@ func (mf MediaFile) CoverArtID() ArtworkID { // otherwise it returns the album artwork ID. func (mf MediaFile) DiscCoverArtID() ArtworkID { if mf.DiscNumber > 0 { - return NewArtworkID(KindDiscArtwork, DiscArtworkID(mf.AlbumID, mf.DiscNumber), nil) + id := NewArtworkID(KindDiscArtwork, DiscArtworkID(mf.AlbumID, mf.DiscNumber), nil) + id.Hash = mf.ImageHash + return id } return mf.AlbumCoverArtID() } func (mf MediaFile) AlbumCoverArtID() ArtworkID { - return artworkIDFromAlbum(Album{ID: mf.AlbumID}) + return artworkIDFromAlbum(Album{ID: mf.AlbumID, ItemImage: mf.ItemImage}) } func (mf MediaFile) StructuredLyrics() (LyricList, error) { diff --git a/model/playlist.go b/model/playlist.go index b5726e4ff..ac64cad62 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -13,6 +13,7 @@ import ( type Playlist struct { Annotations `structs:"-"` + ItemImage `structs:"-" json:"-"` ID string `structs:"id" json:"id"` Name string `structs:"name" json:"name"` diff --git a/model/radio.go b/model/radio.go index 7f654b665..2a90d8c76 100644 --- a/model/radio.go +++ b/model/radio.go @@ -7,6 +7,8 @@ import ( ) type Radio struct { + ItemImage `structs:"-" json:"-"` + ID string `structs:"id" json:"id"` StreamUrl string `structs:"stream_url" json:"streamUrl"` Name string `structs:"name" json:"name"` diff --git a/model/radio_test.go b/model/radio_test.go index 860331f17..6ae74c3f4 100644 --- a/model/radio_test.go +++ b/model/radio_test.go @@ -14,12 +14,11 @@ import ( var _ = Describe("Radio", func() { Describe("CoverArtID", func() { It("returns a radio artwork ID", func() { - now := time.Now() - r := model.Radio{ID: "rd-1", UpdatedAt: now} + r := model.Radio{ID: "rd-1", UpdatedAt: time.Now()} artID := r.CoverArtID() Expect(artID.Kind).To(Equal(model.KindRadioArtwork)) Expect(artID.ID).To(Equal("rd-1")) - Expect(artID.LastUpdate).To(Equal(now)) + Expect(artID.LastUpdate.IsZero()).To(BeTrue()) }) }) diff --git a/server/subsonic/radio_test.go b/server/subsonic/radio_test.go index e959ebe29..2e527c840 100644 --- a/server/subsonic/radio_test.go +++ b/server/subsonic/radio_test.go @@ -71,7 +71,7 @@ var _ = Describe("Radio", func() { Expect(err).ToNot(HaveOccurred()) Expect(response.InternetRadioStations.Radios).To(HaveLen(2)) Expect(response.InternetRadioStations.Radios[0].OpenSubsonicRadio).ToNot(BeNil()) - Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("ra-rd-1_0")) + Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("ra-rd-1")) Expect(response.InternetRadioStations.Radios[1].OpenSubsonicRadio).ToNot(BeNil()) Expect(response.InternetRadioStations.Radios[1].CoverArt).To(BeEmpty()) }) @@ -129,7 +129,7 @@ var _ = Describe("Radio", func() { Expect(err).ToNot(HaveOccurred()) Expect(response.InternetRadioStations.Radios[0].OpenSubsonicRadio).ToNot(BeNil()) - Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("ra-rd-1_0")) + Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("ra-rd-1")) }) }) From 2ab1323b28b8c66bed6a6d39e1a6abc2a39d5e88 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 20:25:17 -0400 Subject: [PATCH 57/87] feat(persistence): hydrate artwork hash and absence onto entity pages --- model/artwork_id.go | 5 + persistence/album_repository.go | 20 +- persistence/artist_repository.go | 18 +- persistence/artwork_hydration.go | 31 +++ persistence/artwork_hydration_test.go | 257 +++++++++++++++++++ persistence/artwork_queue_repository_test.go | 1 + persistence/artwork_repository_test.go | 1 + persistence/mediafile_repository.go | 40 ++- persistence/playlist_repository.go | 19 +- persistence/radio_repository.go | 26 +- 10 files changed, 409 insertions(+), 9 deletions(-) create mode 100644 persistence/artwork_hydration.go create mode 100644 persistence/artwork_hydration_test.go diff --git a/model/artwork_id.go b/model/artwork_id.go index 15db720e5..e308cb676 100644 --- a/model/artwork_id.go +++ b/model/artwork_id.go @@ -17,6 +17,11 @@ func (k Kind) String() string { return k.name } +// Prefix is the short token used in artwork ids and the item_artwork.item_kind column. +func (k Kind) Prefix() string { + return k.prefix +} + var ( KindMediaFileArtwork = Kind{"mf", "media_file"} KindArtistArtwork = Kind{"ar", "artist"} diff --git a/persistence/album_repository.go b/persistence/album_repository.go index e7b155e6f..426048654 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -251,7 +251,21 @@ func (r *albumRepository) GetAll(options ...model.QueryOptions) (model.Albums, e if err != nil { return nil, err } - return res.toModels(), nil + albums := res.toModels() + r.hydrateArtwork(albums) + return albums, nil +} + +// hydrateArtwork fills each album's ImageHash/ImageAbsent from one batched item_artwork lookup. +func (r *albumRepository) hydrateArtwork(albums model.Albums) { + if len(albums) == 0 { + return + } + ids := slice.Map(albums, func(a model.Album) string { return a.ID }) + infos := hydrateItemImages(r.ctx, r.db, model.KindAlbumArtwork.Prefix(), ids) + for i := range albums { + applyItemImage(infos, albums[i].ID, &albums[i].ItemImage) + } } // GetAllIDs returns just the album IDs for the same row set as GetAll, skipping the @@ -414,7 +428,9 @@ func (r *albumRepository) Search(q string, options ...model.QueryOptions) (model if err != nil { return nil, fmt.Errorf("searching album %q: %w", q, err) } - return res.toModels(), nil + albums := res.toModels() + r.hydrateArtwork(albums) + return albums, nil } func (r *albumRepository) Count(options ...rest.QueryOptions) (int64, error) { diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index f26ade8ba..a78718de4 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -250,6 +250,7 @@ func (r *artistRepository) Get(id string) (*model.Artist, error) { return nil, model.ErrNotFound } res := dba.toModels() + r.hydrateArtwork(res) return &res[0], nil } @@ -261,6 +262,7 @@ func (r *artistRepository) GetAll(options ...model.QueryOptions) (model.Artists, return nil, err } res := dba.toModels() + r.hydrateArtwork(res) return res, err } @@ -273,6 +275,18 @@ func (r *artistRepository) GetAllIDs(options ...model.QueryOptions) ([]string, e return ids, err } +// hydrateArtwork fills each artist's ImageHash/ImageAbsent from one batched item_artwork lookup. +func (r *artistRepository) hydrateArtwork(artists model.Artists) { + if len(artists) == 0 { + return + } + ids := slice.Map(artists, func(a model.Artist) string { return a.ID }) + infos := hydrateItemImages(r.ctx, r.db, model.KindArtistArtwork.Prefix(), ids) + for i := range artists { + applyItemImage(infos, artists[i].ID, &artists[i].ItemImage) + } +} + func (r *artistRepository) GetCursor(options ...model.QueryOptions) (model.ArtistCursor, error) { sel := r.selectArtist(options...) cursor, err := queryWithStableResults[dbArtist](r.sqlRepository, sel) @@ -644,7 +658,9 @@ func (r *artistRepository) Search(q string, options ...model.QueryOptions) (mode if err != nil { return nil, fmt.Errorf("searching artist %q: %w", q, err) } - return res.toModels(), nil + artists := res.toModels() + r.hydrateArtwork(artists) + return artists, nil } // searchScope returns the library IDs the search must be restricted to, or nil to skip the filter diff --git a/persistence/artwork_hydration.go b/persistence/artwork_hydration.go new file mode 100644 index 000000000..bf1060055 --- /dev/null +++ b/persistence/artwork_hydration.go @@ -0,0 +1,31 @@ +package persistence + +import ( + "context" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/pocketbase/dbx" +) + +// hydrateItemImages returns per-item artwork info for a fetched page via one batched query per kind +// (never a join, see spec §6). On error it logs and returns an empty map so the page still renders. +func hydrateItemImages(ctx context.Context, db dbx.Builder, kind string, ids []string) map[string]model.ItemArtworkInfo { + if len(ids) == 0 { + return map[string]model.ItemArtworkInfo{} + } + infos, err := NewArtworkRepository(ctx, db).GetInfoForItems(kind, ids) + if err != nil { + log.Error(ctx, "Failed to hydrate artwork info onto page", "kind", kind, err) + return map[string]model.ItemArtworkInfo{} + } + return infos +} + +// applyItemImage copies a hydration entry onto img; a missing entry leaves it zero (unresolved). +func applyItemImage(infos map[string]model.ItemArtworkInfo, id string, img *model.ItemImage) { + if info, ok := infos[id]; ok { + img.ImageHash = info.Hash + img.ImageAbsent = info.Absent() + } +} diff --git a/persistence/artwork_hydration_test.go b/persistence/artwork_hydration_test.go new file mode 100644 index 000000000..6001221cb --- /dev/null +++ b/persistence/artwork_hydration_test.go @@ -0,0 +1,257 @@ +package persistence + +import ( + "context" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/pocketbase/dbx" +) + +var _ = Describe("Artwork hydration", func() { + var ctx context.Context + var aw model.ArtworkRepository + + putInfo := func(kind, id, hash string) { + Expect(aw.PutItemArtwork(&model.ItemArtwork{ + ItemKind: kind, ItemID: id, ImageType: model.ImageTypePrimary, Hash: hash, + })).To(Succeed()) + } + + BeforeEach(func() { + clearArtworkTables() + DeferCleanup(clearArtworkTables) + ctx = request.WithUser(log.NewContext(context.Background()), adminUser) + aw = NewArtworkRepository(ctx, GetDBXBuilder()) + }) + + Describe("albums", func() { + var repo model.AlbumRepository + BeforeEach(func() { repo = NewAlbumRepository(ctx, GetDBXBuilder()) }) + + It("hydrates the found / known-absent / unresolved states", func() { + putInfo("al", albumSgtPeppers.ID, "althash11111111") + putInfo("al", albumAbbeyRoad.ID, "") + // albumRadioactivity: no row -> unresolved + + byID := map[string]model.Album{} + all, err := repo.GetAll() + Expect(err).ToNot(HaveOccurred()) + for _, a := range all { + byID[a.ID] = a + } + + Expect(byID[albumSgtPeppers.ID].ImageHash).To(Equal("althash11111111")) + Expect(byID[albumSgtPeppers.ID].ImageAbsent).To(BeFalse()) + Expect(byID[albumAbbeyRoad.ID].ImageHash).To(BeEmpty()) + Expect(byID[albumAbbeyRoad.ID].ImageAbsent).To(BeTrue()) + Expect(byID[albumRadioactivity.ID].ImageHash).To(BeEmpty()) + Expect(byID[albumRadioactivity.ID].ImageAbsent).To(BeFalse()) + }) + + It("hydrates Get", func() { + putInfo("al", albumSgtPeppers.ID, "gethash22222222") + got, err := repo.Get(albumSgtPeppers.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.ImageHash).To(Equal("gethash22222222")) + }) + + It("hydrates Search", func() { + putInfo("al", albumSgtPeppers.ID, "srchash33333333") + res, err := repo.Search("Peppers", model.QueryOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(res).ToNot(BeEmpty()) + Expect(res[0].ImageHash).To(Equal("srchash33333333")) + }) + + It("does not persist ImageHash/ImageAbsent on Put", func() { + al := albumSgtPeppers + al.ImageHash = "shouldnotpersist" + al.ImageAbsent = true + Expect(repo.(*albumRepository).Put(&al)).To(Succeed()) + + // No item_artwork rows exist, so a fresh read must observe zero values. + got, err := repo.Get(al.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.ImageHash).To(BeEmpty()) + Expect(got.ImageAbsent).To(BeFalse()) + }) + }) + + Describe("artists", func() { + var repo model.ArtistRepository + BeforeEach(func() { repo = NewArtistRepository(ctx, GetDBXBuilder()) }) + + It("hydrates the found / known-absent / unresolved states", func() { + putInfo("ar", artistBeatles.ID, "arhash444444444") + putInfo("ar", artistKraftwerk.ID, "") + // artistCJK: no row -> unresolved + + byID := map[string]model.Artist{} + all, err := repo.GetAll() + Expect(err).ToNot(HaveOccurred()) + for _, a := range all { + byID[a.ID] = a + } + + Expect(byID[artistBeatles.ID].ImageHash).To(Equal("arhash444444444")) + Expect(byID[artistBeatles.ID].ImageAbsent).To(BeFalse()) + Expect(byID[artistKraftwerk.ID].ImageHash).To(BeEmpty()) + Expect(byID[artistKraftwerk.ID].ImageAbsent).To(BeTrue()) + Expect(byID[artistCJK.ID].ImageHash).To(BeEmpty()) + Expect(byID[artistCJK.ID].ImageAbsent).To(BeFalse()) + }) + + It("hydrates Get", func() { + putInfo("ar", artistBeatles.ID, "arget5555555555") + got, err := repo.Get(artistBeatles.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.ImageHash).To(Equal("arget5555555555")) + }) + + It("hydrates Search", func() { + putInfo("ar", artistBeatles.ID, "arsrch666666666") + res, err := repo.Search("Beatles", model.QueryOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(res).ToNot(BeEmpty()) + Expect(res[0].ImageHash).To(Equal("arsrch666666666")) + }) + }) + + Describe("playlists", func() { + var repo model.PlaylistRepository + BeforeEach(func() { repo = NewPlaylistRepository(ctx, GetDBXBuilder()) }) + + It("hydrates the found / known-absent states", func() { + putInfo("pl", plsBest.ID, "plhash777777777") + putInfo("pl", plsCool.ID, "") + + byID := map[string]model.Playlist{} + all, err := repo.GetAll() + Expect(err).ToNot(HaveOccurred()) + for _, p := range all { + byID[p.ID] = p + } + + Expect(byID[plsBest.ID].ImageHash).To(Equal("plhash777777777")) + Expect(byID[plsBest.ID].ImageAbsent).To(BeFalse()) + Expect(byID[plsCool.ID].ImageHash).To(BeEmpty()) + Expect(byID[plsCool.ID].ImageAbsent).To(BeTrue()) + }) + + It("hydrates Get", func() { + putInfo("pl", plsBest.ID, "plget8888888888") + got, err := repo.Get(plsBest.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.ImageHash).To(Equal("plget8888888888")) + }) + }) + + Describe("radios", func() { + var repo model.RadioRepository + BeforeEach(func() { repo = NewRadioRepository(ctx, GetDBXBuilder()) }) + + It("hydrates the found / known-absent states", func() { + putInfo("ra", radioWithHomePage.ID, "rahash999999999") + putInfo("ra", radioWithoutHomePage.ID, "") + + byID := map[string]model.Radio{} + all, err := repo.GetAll() + Expect(err).ToNot(HaveOccurred()) + for _, rd := range all { + byID[rd.ID] = rd + } + + Expect(byID[radioWithHomePage.ID].ImageHash).To(Equal("rahash999999999")) + Expect(byID[radioWithHomePage.ID].ImageAbsent).To(BeFalse()) + Expect(byID[radioWithoutHomePage.ID].ImageHash).To(BeEmpty()) + Expect(byID[radioWithoutHomePage.ID].ImageAbsent).To(BeTrue()) + }) + + It("hydrates Get", func() { + putInfo("ra", radioWithHomePage.ID, "ragetaaaaaaaaaa") + got, err := repo.Get(radioWithHomePage.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.ImageHash).To(Equal("ragetaaaaaaaaaa")) + }) + }) + + Describe("mediafiles", func() { + var repo model.MediaFileRepository + + setCover := func(id string, v bool) { + _, err := GetDBXBuilder().NewQuery("UPDATE media_file SET has_cover_art={:v} WHERE id={:id}"). + Bind(dbx.Params{"v": v, "id": id}).Execute() + Expect(err).ToNot(HaveOccurred()) + } + + getByID := func() map[string]model.MediaFile { + byID := map[string]model.MediaFile{} + all, err := repo.GetAll() + Expect(err).ToNot(HaveOccurred()) + for _, mf := range all { + byID[mf.ID] = mf + } + return byID + } + + BeforeEach(func() { + repo = NewMediaFileRepository(ctx, GetDBXBuilder()) + DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableMediaFileCoverArt = true + }) + + It("resolves the embedded-eligible fallback matrix", func() { + setCover("1001", true) // eligible, own hash + setCover("1002", true) // eligible, but embedded art absent -> album + DeferCleanup(func() { setCover("1001", false); setCover("1002", false) }) + + putInfo("al", "101", "alh101xxxxxxxxxx") // song 1001's album (found) + putInfo("al", "102", "alh102xxxxxxxxxx") // song 1002's album (found) + putInfo("al", "103", "") // songs 1003/1004 album known-absent + putInfo("mf", "1001", "mfh1001xxxxxxxx") + putInfo("mf", "1002", "") // embedded resolved absent + + byID := getByID() + + // eligible + own hash -> own hash + Expect(byID["1001"].ImageHash).To(Equal("mfh1001xxxxxxxx")) + Expect(byID["1001"].ImageAbsent).To(BeFalse()) + // eligible + embedded absent -> falls through to album 102 info + Expect(byID["1002"].ImageHash).To(Equal("alh102xxxxxxxxxx")) + Expect(byID["1002"].ImageAbsent).To(BeFalse()) + // not eligible (no embedded cover) -> album 103 info (known-absent) + Expect(byID["1003"].ImageHash).To(BeEmpty()) + Expect(byID["1003"].ImageAbsent).To(BeTrue()) + // not eligible, album has no row -> zero values (unresolved) + Expect(byID["2002"].ImageHash).To(BeEmpty()) + Expect(byID["2002"].ImageAbsent).To(BeFalse()) + }) + + It("uses album info for an eligible file when EnableMediaFileCoverArt is off", func() { + conf.Server.EnableMediaFileCoverArt = false + setCover("1001", true) + DeferCleanup(func() { setCover("1001", false) }) + + putInfo("al", "101", "alh101offxxxxxxx") + putInfo("mf", "1001", "mfh1001offxxxxx") + + byID := getByID() + Expect(byID["1001"].ImageHash).To(Equal("alh101offxxxxxxx")) + Expect(byID["1001"].ImageAbsent).To(BeFalse()) + }) + + It("hydrates Search", func() { + putInfo("al", "101", "alsrchhhhhhhhhhh") + res, err := repo.Search("A Day In A Life", model.QueryOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(res).ToNot(BeEmpty()) + Expect(res[0].ImageHash).To(Equal("alsrchhhhhhhhhhh")) + }) + }) +}) diff --git a/persistence/artwork_queue_repository_test.go b/persistence/artwork_queue_repository_test.go index 0a6ddefdc..7ccef59ef 100644 --- a/persistence/artwork_queue_repository_test.go +++ b/persistence/artwork_queue_repository_test.go @@ -19,6 +19,7 @@ var _ = Describe("ArtworkQueueRepository", func() { BeforeEach(func() { clearArtworkTables() + DeferCleanup(clearArtworkTables) repo = NewArtworkQueueRepository(context.Background(), GetDBXBuilder()) }) diff --git a/persistence/artwork_repository_test.go b/persistence/artwork_repository_test.go index ae6752def..ae9f15fda 100644 --- a/persistence/artwork_repository_test.go +++ b/persistence/artwork_repository_test.go @@ -25,6 +25,7 @@ var _ = Describe("ArtworkRepository", func() { BeforeEach(func() { clearArtworkTables() + DeferCleanup(clearArtworkTables) repo = NewArtworkRepository(context.Background(), GetDBXBuilder()) }) diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index ace61610c..9ba9d3825 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -218,7 +218,37 @@ func (r *mediaFileRepository) GetAll(options ...model.QueryOptions) (model.Media if err != nil { return nil, err } - return res.toModels(), nil + mfs := res.toModels() + r.hydrateArtwork(mfs) + return mfs, nil +} + +// hydrateArtwork mirrors MediaFile.CoverArtID: an embedded-eligible file with resolved own art uses +// it, else it falls back to the album's. Two batched item_artwork lookups per page, never a join. +func (r *mediaFileRepository) hydrateArtwork(mfs model.MediaFiles) { + if len(mfs) == 0 { + return + } + albumIDs := make([]string, len(mfs)) + var eligibleIDs []string + for i := range mfs { + albumIDs[i] = mfs[i].AlbumID + if mfs[i].HasCoverArt && conf.Server.EnableMediaFileCoverArt { + eligibleIDs = append(eligibleIDs, mfs[i].ID) + } + } + albumInfos := hydrateItemImages(r.ctx, r.db, model.KindAlbumArtwork.Prefix(), albumIDs) + mfInfos := hydrateItemImages(r.ctx, r.db, model.KindMediaFileArtwork.Prefix(), eligibleIDs) + for i := range mfs { + if mfs[i].HasCoverArt && conf.Server.EnableMediaFileCoverArt { + if info, ok := mfInfos[mfs[i].ID]; ok && !info.Absent() { + mfs[i].ImageHash = info.Hash + mfs[i].ImageAbsent = false + continue + } + } + applyItemImage(albumInfos, mfs[i].AlbumID, &mfs[i].ItemImage) + } } // GetRandom uses two passes so the random sort runs over a narrow rowid index instead of the @@ -252,7 +282,9 @@ func (r *mediaFileRepository) GetRandom(options ...model.QueryOptions) (model.Me if err := r.queryAll(sq, &res); err != nil { return nil, err } - return res.toModels(), nil + mfs := res.toModels() + r.hydrateArtwork(mfs) + return mfs, nil } func (r *mediaFileRepository) GetAllByTags(tag model.TagName, values []string, options ...model.QueryOptions) (model.MediaFiles, error) { @@ -487,7 +519,9 @@ func (r *mediaFileRepository) Search(q string, options ...model.QueryOptions) (m if err != nil { return nil, fmt.Errorf("searching media_file %q: %w", q, err) } - return res.toModels(), nil + mfs := res.toModels() + r.hydrateArtwork(mfs) + return mfs, nil } func (r *mediaFileRepository) Count(options ...rest.QueryOptions) (int64, error) { diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 0abf53d93..e984b8852 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -14,6 +14,7 @@ import ( "github.com/deluan/rest" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" "github.com/pocketbase/dbx" ) @@ -172,7 +173,21 @@ func (r *playlistRepository) findBy(sql Sqlizer) (*model.Playlist, error) { return nil, model.ErrNotFound } - return &pls[0].Playlist, nil + list := model.Playlists{pls[0].Playlist} + r.hydrateArtwork(list) + return &list[0], nil +} + +// hydrateArtwork fills each playlist's ImageHash/ImageAbsent from one batched item_artwork lookup. +func (r *playlistRepository) hydrateArtwork(playlists model.Playlists) { + if len(playlists) == 0 { + return + } + ids := slice.Map(playlists, func(p model.Playlist) string { return p.ID }) + infos := hydrateItemImages(r.ctx, r.db, model.KindPlaylistArtwork.Prefix(), ids) + for i := range playlists { + applyItemImage(infos, playlists[i].ID, &playlists[i].ItemImage) + } } func (r *playlistRepository) GetAll(options ...model.QueryOptions) (model.Playlists, error) { @@ -186,6 +201,7 @@ func (r *playlistRepository) GetAll(options ...model.QueryOptions) (model.Playli for i, p := range res { playlists[i] = p.Playlist } + r.hydrateArtwork(playlists) return playlists, err } @@ -230,6 +246,7 @@ func (r *playlistRepository) GetPlaylists(mediaFileId string) (model.Playlists, for i, p := range res { playlists[i] = p.Playlist } + r.hydrateArtwork(playlists) return playlists, nil } diff --git a/persistence/radio_repository.go b/persistence/radio_repository.go index 3ef900920..d48178977 100644 --- a/persistence/radio_repository.go +++ b/persistence/radio_repository.go @@ -10,6 +10,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" + "github.com/navidrome/navidrome/utils/slice" "github.com/pocketbase/dbx" ) @@ -49,14 +50,35 @@ func (r *radioRepository) Get(id string) (*model.Radio, error) { sel := r.newSelect().Where(Eq{"id": id}).Columns("*") res := model.Radio{} err := r.queryOne(sel, &res) - return &res, err + if err != nil { + return &res, err + } + list := model.Radios{res} + r.hydrateArtwork(list) + return &list[0], nil } func (r *radioRepository) GetAll(options ...model.QueryOptions) (model.Radios, error) { sel := r.newSelect(options...).Columns("*") res := model.Radios{} err := r.queryAll(sel, &res) - return res, err + if err != nil { + return res, err + } + r.hydrateArtwork(res) + return res, nil +} + +// hydrateArtwork fills each radio's ImageHash/ImageAbsent from one batched item_artwork lookup. +func (r *radioRepository) hydrateArtwork(radios model.Radios) { + if len(radios) == 0 { + return + } + ids := slice.Map(radios, func(rd model.Radio) string { return rd.ID }) + infos := hydrateItemImages(r.ctx, r.db, model.KindRadioArtwork.Prefix(), ids) + for i := range radios { + applyItemImage(infos, radios[i].ID, &radios[i].ItemImage) + } } // GetAllIDs returns just the radio IDs. Used by bulk enumeration (artwork backfill). From 51796918115937eb1d76043cd5bb7464104c5f10 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 20:40:26 -0400 Subject: [PATCH 58/87] feat(artwork): resolve media_file embedded art in the worker, invalidate on rescan --- core/artwork/resolve.go | 20 ++++++++ core/artwork/resolve_test.go | 52 +++++++++++++++++++- core/artwork/worker_test.go | 33 +++++++++++++ model/artwork.go | 2 + persistence/artwork_queue_repository_test.go | 6 ++- persistence/artwork_repository.go | 10 ++++ persistence/artwork_repository_test.go | 27 +++++++++- scanner/phase_1_folders.go | 11 +++++ scanner/scanner_test.go | 20 ++++++++ tests/mock_artwork_repo.go | 16 ++++++ 10 files changed, 193 insertions(+), 4 deletions(-) diff --git a/core/artwork/resolve.go b/core/artwork/resolve.go index 6d41897e5..22ad3de52 100644 --- a/core/artwork/resolve.go +++ b/core/artwork/resolve.go @@ -49,6 +49,8 @@ func resolveItem(ctx context.Context, ds model.DataStore, ag *agents.Agents, ffm return resolvePlaylist(ctx, ds, ag, ffmpeg, item.ItemID, gate) case "ra": return resolveRadio(ctx, ds, item.ItemID) + case "mf": + return resolveMediaFile(ctx, ds, ffmpeg, item.ItemID) default: return resolution{}, fmt.Errorf("resolveItem: kind %q is not resolvable by the worker", item.ItemKind) } @@ -251,6 +253,24 @@ func resolveRadio(ctx context.Context, ds model.DataStore, radioID string) (reso return res, nil } +// resolveMediaFile resolves a track's own embedded art only; there is no folder or +// external fallback, so disabled/missing cover art is a definitive absent. +func resolveMediaFile(ctx context.Context, ds model.DataStore, ffm ffmpeg.FFmpeg, id string) (resolution, error) { + mf, err := ds.MediaFile(ctx).Get(id) + if err != nil { + return resolution{}, err + } + if !conf.Server.EnableMediaFileCoverArt || !mf.HasCoverArt { + return resolution{}, nil + } + lib, err := loadLibraryView(ctx, ds, mf.LibraryID) + if err != nil { + return resolution{}, err + } + res, _ := resolveEmbedded(ctx, lib, ffm, mf.Path) + return res, nil +} + // resolveExternalStep runs a single external sourceFunc through the named gate; used by // the playlist ExternalImageURL step. ok reports a hit; extErr reports a non-not-found // error (a not-found is a definitive "no", not a failure). diff --git a/core/artwork/resolve_test.go b/core/artwork/resolve_test.go index cce1554da..8309a6cc3 100644 --- a/core/artwork/resolve_test.go +++ b/core/artwork/resolve_test.go @@ -51,11 +51,61 @@ var _ = Describe("resolveItem", func() { Describe("kind dispatch", func() { It("returns an error for kinds the worker never enqueues", func() { - _, err := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "x"}, nil) + _, err := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "zz", ItemID: "x"}, nil) Expect(err).To(HaveOccurred()) }) }) + Describe("media file", func() { + BeforeEach(func() { + conf.Server.EnableMediaFileCoverArt = true + ds.MockedMediaFile = tests.CreateMockMediaFileRepo() + }) + + It("resolves embedded art from the track file", func() { + ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "mf1", LibraryID: 0, Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: true}, + }) + + res, err := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mf1"}, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(res.source).To(Equal("embedded")) + Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/test.mp3")) + Expect(res.refMtime).To(BeNumerically(">", 0)) + Expect(res.extError).To(BeFalse()) + }) + + It("resolves absent when the track has no cover art", func() { + ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "mf2", LibraryID: 0, Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: false}, + }) + + res, err := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mf2"}, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).To(BeNil()) + Expect(res.extError).To(BeFalse()) + }) + + It("resolves absent when media file cover art is disabled", func() { + conf.Server.EnableMediaFileCoverArt = false + ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "mf3", LibraryID: 0, Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: true}, + }) + + res, err := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mf3"}, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).To(BeNil()) + }) + + It("returns the error when the track is not in the DB", func() { + res, err := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "missing"}, nil) + Expect(err).To(MatchError(model.ErrNotFound)) + Expect(res.reader).To(BeNil()) + }) + }) + Describe("album", func() { BeforeEach(func() { conf.Server.CoverArtPriority = "cover.jpg, embedded" diff --git a/core/artwork/worker_test.go b/core/artwork/worker_test.go index cf9b7cddf..cee64987f 100644 --- a/core/artwork/worker_test.go +++ b/core/artwork/worker_test.go @@ -115,6 +115,39 @@ var _ = Describe("Worker", func() { Expect(count).To(BeZero(), "a found item must be deleted from the queue") }) + It("processes an mf queue item, writing state and storing embedded bytes", func() { + conf.Server.EnableMediaFileCoverArt = true + ds.MockedMediaFile = tests.CreateMockMediaFileRepo() + ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "mf1", LibraryID: 0, Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: true}, + }) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ + ItemKind: "mf", ItemID: "mf1", Priority: model.ArtworkPriorityBump, + })).To(Succeed()) + + n, err := w.drain(ctx, 2) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(1)) + + ia, err := artRepo.GetItemArtwork("mf", "mf1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("embedded")) + Expect(ia.Hash).ToNot(BeEmpty()) + + art, err := artRepo.GetImage(ia.Hash) + Expect(err).ToNot(HaveOccurred()) + r, err := store.Open(ia.Hash, art.Mime) + Expect(err).ToNot(HaveOccurred()) + defer r.Close() + data, err := io.ReadAll(r) + Expect(err).ToNot(HaveOccurred()) + Expect(data).ToNot(BeEmpty(), "embedded bytes must be written to the store") + + count, err := queueRepo.Count() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(BeZero()) + }) + It("reschedules a failed item via MarkFailed with a backed-off retry_at", func() { conf.Server.CoverArtPriority = "external" ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al4", Name: "Album"}}) diff --git a/model/artwork.go b/model/artwork.go index a4f9e01e1..88b2d009d 100644 --- a/model/artwork.go +++ b/model/artwork.go @@ -80,6 +80,8 @@ type ArtworkRepository interface { GetItemArtwork(kind, id, imageType string) (*ItemArtwork, error) PutItemArtwork(ia *ItemArtwork) error DeleteForItem(kind, id string) error + // DeleteForItems removes state rows for the given ids of one kind, in chunks. + DeleteForItems(kind string, ids []string) error // GetInfoForItems hydrates a page: one batched query, item_artwork joined to artwork. GetInfoForItems(kind string, ids []string) (map[string]ItemArtworkInfo, error) // GetAllMimes returns hash -> current mime for every stored artwork, for sweep retention checks. diff --git a/persistence/artwork_queue_repository_test.go b/persistence/artwork_queue_repository_test.go index 7ccef59ef..9a4054cb9 100644 --- a/persistence/artwork_queue_repository_test.go +++ b/persistence/artwork_queue_repository_test.go @@ -127,18 +127,20 @@ var _ = Describe("ArtworkQueueRepository", func() { item("pl", "no-such-playlist", model.ArtworkPriorityScan), item("ra", radioWithHomePage.ID, model.ArtworkPriorityScan), item("ra", "no-such-radio", model.ArtworkPriorityScan), + item("mf", songDayInALife.ID, model.ArtworkPriorityScan), + item("mf", "no-such-mediafile", model.ArtworkPriorityScan), )).To(Succeed()) purged, err := repo.PurgeDangling() Expect(err).ToNot(HaveOccurred()) - Expect(purged).To(Equal(int64(4))) + Expect(purged).To(Equal(int64(5))) got, _ := repo.DequeueBatch(100) ids := make([]string, 0, len(got)) for _, it := range got { ids = append(ids, it.ItemID) } - Expect(ids).To(ConsistOf(albumSgtPeppers.ID, artistKraftwerk.ID, plsBest.ID, radioWithHomePage.ID)) + Expect(ids).To(ConsistOf(albumSgtPeppers.ID, artistKraftwerk.ID, plsBest.ID, radioWithHomePage.ID, songDayInALife.ID)) }) It("enqueues stale absent states for recheck", func() { diff --git a/persistence/artwork_repository.go b/persistence/artwork_repository.go index 9ac32b42e..e21a69c63 100644 --- a/persistence/artwork_repository.go +++ b/persistence/artwork_repository.go @@ -120,6 +120,7 @@ var danglingItemArtworkKinds = map[string]string{ "ar": "artist", "pl": "playlist", "ra": "radio", + "mf": "media_file", } // purgeDangling deletes rows in table whose owning entity is gone, one statement per kind. @@ -177,6 +178,15 @@ func (r *artworkRepository) DeleteForItem(kind, id string) error { return r.items.delete(Eq{"item_kind": kind, "item_id": id}) } +func (r *artworkRepository) DeleteForItems(kind string, ids []string) error { + for chunk := range slices.Chunk(ids, artworkBatchSize) { + if err := r.items.delete(Eq{"item_kind": kind, "item_id": chunk}); err != nil { + return err + } + } + return nil +} + func (r *artworkRepository) GetInfoForItems(kind string, ids []string) (map[string]model.ItemArtworkInfo, error) { res := map[string]model.ItemArtworkInfo{} for chunk := range slices.Chunk(ids, artworkBatchSize) { diff --git a/persistence/artwork_repository_test.go b/persistence/artwork_repository_test.go index ae9f15fda..c1f1ad741 100644 --- a/persistence/artwork_repository_test.go +++ b/persistence/artwork_repository_test.go @@ -147,16 +147,19 @@ var _ = Describe("ArtworkRepository", func() { Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "pl", ItemID: "no-such-playlist", ImageType: model.ImageTypePrimary, Hash: "danglingPl"})).To(Succeed()) Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ra", ItemID: radioWithHomePage.ID, ImageType: model.ImageTypePrimary, Hash: "keepRa"})).To(Succeed()) Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ra", ItemID: "no-such-radio", ImageType: model.ImageTypePrimary, Hash: "danglingRa"})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "mf", ItemID: songDayInALife.ID, ImageType: model.ImageTypePrimary, Hash: "keepMf"})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "mf", ItemID: "no-such-mediafile", ImageType: model.ImageTypePrimary, Hash: "danglingMf"})).To(Succeed()) purged, err := repo.PurgeDanglingItemArtwork() Expect(err).ToNot(HaveOccurred()) - Expect(purged).To(Equal(int64(4))) + Expect(purged).To(Equal(int64(5))) for _, kept := range []model.ItemArtwork{ {ItemKind: "al", ItemID: albumSgtPeppers.ID}, {ItemKind: "ar", ItemID: artistKraftwerk.ID}, {ItemKind: "pl", ItemID: plsBest.ID}, {ItemKind: "ra", ItemID: radioWithHomePage.ID}, + {ItemKind: "mf", ItemID: songDayInALife.ID}, } { _, err := repo.GetItemArtwork(kept.ItemKind, kept.ItemID, model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) @@ -166,6 +169,7 @@ var _ = Describe("ArtworkRepository", func() { {ItemKind: "ar", ItemID: "no-such-artist"}, {ItemKind: "pl", ItemID: "no-such-playlist"}, {ItemKind: "ra", ItemID: "no-such-radio"}, + {ItemKind: "mf", ItemID: "no-such-mediafile"}, } { _, err := repo.GetItemArtwork(gone.ItemKind, gone.ItemID, model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound)) @@ -230,5 +234,26 @@ var _ = Describe("ArtworkRepository", func() { _, err := repo.GetItemArtwork("pl", "p1", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound)) }) + + It("deletes rows for many items in chunks, leaving others untouched", func() { + const n = artworkBatchSize + 5 + ids := make([]string, n) + for i := range n { + id := fmt.Sprintf("mf-%d", i) + ids[i] = id + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "mf", ItemID: id, ImageType: model.ImageTypePrimary, Hash: "h1"})).To(Succeed()) + } + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "mf", ItemID: "keep", ImageType: model.ImageTypePrimary, Hash: "h1"})).To(Succeed()) + + Expect(repo.DeleteForItems("mf", ids)).To(Succeed()) + + for _, id := range ids { + _, err := repo.GetItemArtwork("mf", id, model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + } + kept, err := repo.GetItemArtwork("mf", "keep", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(kept.ItemID).To(Equal("keep")) + }) }) }) diff --git a/scanner/phase_1_folders.go b/scanner/phase_1_folders.go index 3a7265cc4..65f26b9d9 100644 --- a/scanner/phase_1_folders.go +++ b/scanner/phase_1_folders.go @@ -406,6 +406,17 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error) } } + // A re-imported track returns to unresolved so new embedded art is picked up lazily. + if len(entry.tracks) > 0 { + trackIDs := make([]string, len(entry.tracks)) + for i := range entry.tracks { + trackIDs[i] = entry.tracks[i].ID + } + if err := tx.Artwork(p.ctx).DeleteForItems("mf", trackIDs); err != nil { + log.Warn(p.ctx, "Scanner: could not invalidate media_file artwork", "folder", entry.path, err) + } + } + // Mark all missing tracks as not available if len(entry.missingTracks) > 0 { err = mfRepo.MarkMissing(true, entry.missingTracks...) diff --git a/scanner/scanner_test.go b/scanner/scanner_test.go index 0a7fb07b2..b8044c587 100644 --- a/scanner/scanner_test.go +++ b/scanner/scanner_test.go @@ -210,6 +210,26 @@ var _ = Describe("Scanner", Ordered, func() { Expect(albums[0].Participants.First(model.RoleProducer).Name).To(Equal("George Martin")) Expect(albums[0].SongCount).To(Equal(3)) }) + + It("invalidates the media_file artwork state so new embedded art is picked up lazily", func() { + Expect(runScanner(ctx, true)).To(Succeed()) + + mf, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"title": "Help!"}}) + Expect(err).ToNot(HaveOccurred()) + Expect(mf).ToNot(BeEmpty()) + trackID := mf[0].ID + + Expect(ds.Artwork(ctx).PutItemArtwork(&model.ItemArtwork{ + ItemKind: "mf", ItemID: trackID, ImageType: model.ImageTypePrimary, + Source: "embedded", Hash: "stalehash", + })).To(Succeed()) + + fsys.UpdateTags("The Beatles/Help!/01 - Help!.mp3", _t{"comment": "reimport"}) + Expect(runScanner(ctx, true)).To(Succeed()) + + _, err = ds.Artwork(ctx).GetItemArtwork("mf", trackID, model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + }) }) }) diff --git a/tests/mock_artwork_repo.go b/tests/mock_artwork_repo.go index db73af84d..9e9f84272 100644 --- a/tests/mock_artwork_repo.go +++ b/tests/mock_artwork_repo.go @@ -152,6 +152,22 @@ func (m *MockArtworkRepo) DeleteForItem(kind, id string) error { return nil } +func (m *MockArtworkRepo) DeleteForItems(kind string, ids []string) error { + if m.Err != nil { + return m.Err + } + idSet := make(map[string]bool, len(ids)) + for _, id := range ids { + idSet[id] = true + } + for k, ia := range m.ItemData { + if ia.ItemKind == kind && idSet[ia.ItemID] { + 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 From 05ba549843b337c200775fabadd8f4adbf79d8cc Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 20:53:22 -0400 Subject: [PATCH 59/87] feat(artwork): broadcast refresh events when artwork lands --- cmd/wire_gen.go | 2 +- core/artwork/worker.go | 49 ++++++++++++++++-- core/artwork/worker_test.go | 83 ++++++++++++++++++++++++++++-- core/artwork/worker_timing_test.go | 2 +- tests/mock_artwork_queue_repo.go | 21 ++++++++ tests/mock_artwork_repo.go | 27 ++++++++++ 6 files changed, 174 insertions(+), 10 deletions(-) diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 6a500df3c..8f11b2efc 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -245,7 +245,7 @@ func CreateArtworkWorker() *artwork.Worker { manager := plugins.GetManager(dataStore, broker, metricsMetrics) agentsAgents := agents.GetAgents(dataStore, manager) fFmpeg := ffmpeg.New() - worker := artwork.NewWorker(dataStore, imageStore, agentsAgents, fFmpeg) + worker := artwork.NewWorker(dataStore, imageStore, agentsAgents, fFmpeg, broker) return worker } diff --git a/core/artwork/worker.go b/core/artwork/worker.go index 9365cf50c..3261f35a7 100644 --- a/core/artwork/worker.go +++ b/core/artwork/worker.go @@ -15,6 +15,7 @@ import ( "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/events" "golang.org/x/time/rate" ) @@ -40,6 +41,7 @@ type extGate struct { // via pruneMu. type Worker struct { deps workerDeps + broker events.Broker pruneMu sync.RWMutex wake chan struct{} runCtx context.Context @@ -51,9 +53,10 @@ type Worker struct { inFlight map[string]struct{} } -func NewWorker(ds model.DataStore, store *ImageStore, ag *agents.Agents, ffmpeg ffmpeg.FFmpeg) *Worker { +func NewWorker(ds model.DataStore, store *ImageStore, ag *agents.Agents, ffmpeg ffmpeg.FFmpeg, broker events.Broker) *Worker { w := &Worker{ deps: workerDeps{ds: ds, store: store, agents: ag, ffmpeg: ffmpeg}, + broker: broker, wake: make(chan struct{}, 1), runCtx: context.Background(), gates: map[string]*extGate{}, @@ -131,6 +134,8 @@ func (w *Worker) drain(ctx context.Context, concurrency int) (int, error) { } sem := make(chan struct{}, concurrency) var wg sync.WaitGroup + var foundMu sync.Mutex + var found []model.ArtworkQueueItem for _, item := range items { sem <- struct{}{} wg.Add(1) @@ -138,14 +143,51 @@ func (w *Worker) drain(ctx context.Context, concurrency int) (int, error) { defer wg.Done() defer func() { <-sem }() defer w.release(it) - w.process(ctx, it) + if w.process(ctx, it) == outcomeFound { + foundMu.Lock() + found = append(found, it) + foundMu.Unlock() + } }(item) } wg.Wait() + w.broadcastRefresh(ctx, found) return len(items), nil } -func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) { +// artworkKindToResource maps a queue item's kind to the UI resource name carried +// in the refresh event. +var artworkKindToResource = map[string]string{ + "al": "album", + "ar": "artist", + "pl": "playlist", + "ra": "radio", + "mf": "song", +} + +// broadcastRefresh emits one coalesced RefreshResource for the batch's newly-acquired +// artwork, so connected UIs re-fetch the affected records (and pick up the new coverArt id). +func (w *Worker) broadcastRefresh(ctx context.Context, found []model.ArtworkQueueItem) { + if len(found) == 0 { + return + } + event := &events.RefreshResource{} + byResource := map[string][]string{} + for _, it := range found { + if res, ok := artworkKindToResource[it.ItemKind]; ok { + byResource[res] = append(byResource[res], it.ItemID) + } + } + if len(byResource) == 0 { + return + } + for res, ids := range byResource { + event = event.With(res, ids...) + } + w.broker.SendBroadcastMessage(ctx, event) +} + +func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) outcome { if item.ImageType == "" { item.ImageType = model.ImageTypePrimary } @@ -169,6 +211,7 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) { log.Warn(ctx, "artwork: could not reschedule failed queue item", "kind", item.ItemKind, "id", item.ItemID, err) } } + return out } // claim reserves items not already in flight, so a row appearing twice within a single diff --git a/core/artwork/worker_test.go b/core/artwork/worker_test.go index cee64987f..86a9d37a2 100644 --- a/core/artwork/worker_test.go +++ b/core/artwork/worker_test.go @@ -4,13 +4,16 @@ import ( "context" "errors" "io" + "net/http" "os" + "sync" "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -38,6 +41,32 @@ func (r *reenqueueOnDequeue) DequeueBatch(n int) ([]model.ArtworkQueueItem, erro return items, err } +type fakeEventBroker struct { + http.Handler + mu sync.Mutex + events []events.Event +} + +func (f *fakeEventBroker) SendMessage(_ context.Context, event events.Event) { + f.mu.Lock() + defer f.mu.Unlock() + f.events = append(f.events, event) +} + +func (f *fakeEventBroker) SendBroadcastMessage(_ context.Context, event events.Event) { + f.mu.Lock() + defer f.mu.Unlock() + f.events = append(f.events, event) +} + +func (f *fakeEventBroker) getEvents() []events.Event { + f.mu.Lock() + defer f.mu.Unlock() + return f.events +} + +var _ events.Broker = (*fakeEventBroker)(nil) + func findQueued(q *tests.MockArtworkQueueRepo, kind, id string) *model.ArtworkQueueItem { for _, it := range q.Data { if it.ItemKind == kind && it.ItemID == id { @@ -58,6 +87,7 @@ var _ = Describe("Worker", func() { store *ImageStore artRepo *tests.MockArtworkRepo queueRepo *tests.MockArtworkQueueRepo + broker *fakeEventBroker repoRoot string w *Worker ) @@ -86,7 +116,8 @@ var _ = Describe("Worker", func() { store = NewImageStore(GinkgoT().TempDir()) conf.Server.CoverArtPriority = "cover.jpg, embedded" conf.Server.ArtworkExternalMaxRPS = 1000 // keep the limiter out of the way of behavior tests - w = NewWorker(ds, store, ag, ffm) + broker = &fakeEventBroker{} + w = NewWorker(ds, store, ag, ffm, broker) }) Describe("drain", func() { @@ -203,7 +234,7 @@ var _ = Describe("Worker", func() { }) racing := &reenqueueOnDequeue{MockArtworkQueueRepo: queueRepo} ds.MockedArtworkQueue = racing - w = NewWorker(ds, store, ag, ffm) + w = NewWorker(ds, store, ag, ffm, broker) Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ ItemKind: "al", ItemID: "al7", Priority: model.ArtworkPriorityScan, })).To(Succeed()) @@ -225,7 +256,7 @@ var _ = Describe("Worker", func() { imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) racing := &reenqueueOnDequeue{MockArtworkQueueRepo: queueRepo} ds.MockedArtworkQueue = racing - w = NewWorker(ds, store, ag, ffm) + w = NewWorker(ds, store, ag, ffm, broker) Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al8"})).To(Succeed()) dequeued := findQueued(queueRepo, "al", "al8").RetryAt @@ -248,7 +279,7 @@ var _ = Describe("Worker", func() { private: model.Playlist{ID: "plPriv", OwnerID: "admin"}, tracks: &tests.MockPlaylistTrackRepo{}, } - w = NewWorker(vds, store, ag, ffm) + w = NewWorker(vds, store, ag, ffm, broker) Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plPriv"})).To(Succeed()) n, err := w.drain(ctx, 1) @@ -267,6 +298,48 @@ var _ = Describe("Worker", func() { Expect(err).ToNot(HaveOccurred()) Expect(n).To(BeZero()) }) + + It("broadcasts a single refresh event for the found items in a batch", func() { + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }} + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al1", Name: "Album 1", FolderIDs: []string{"f1"}}, + {ID: "al2", Name: "Album 2", FolderIDs: []string{"f1"}}, + }) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1", Priority: model.ArtworkPriorityScan})).To(Succeed()) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2", Priority: model.ArtworkPriorityScan})).To(Succeed()) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar1", Priority: model.ArtworkPriorityScan})).To(Succeed()) + + n, err := w.drain(ctx, 3) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(3)) + + evts := broker.getEvents() + Expect(evts).To(HaveLen(1), "exactly one coalesced event per drain batch") + rr, ok := evts[0].(*events.RefreshResource) + Expect(ok).To(BeTrue()) + data := rr.Data(rr) + Expect(data).To(ContainSubstring(`"album"`)) + Expect(data).To(ContainSubstring("al1")) + Expect(data).To(ContainSubstring("al2")) + Expect(data).ToNot(ContainSubstring("artist"), "the absent artist must not be refreshed") + Expect(data).ToNot(ContainSubstring("ar1")) + }) + + It("does not broadcast when no item is found", func() { + conf.Server.CoverArtPriority = "external" + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alx", Name: "Album"}}) + imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "alx"})).To(Succeed()) + + n, err := w.drain(ctx, 2) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(1)) + + Expect(broker.getEvents()).To(BeEmpty(), "a drain with no found items sends no event") + }) }) Describe("Bump", func() { @@ -379,7 +452,7 @@ var _ = Describe("Worker", func() { DeferCleanup(func() { goleak.VerifyNone(GinkgoT(), ignore) }) localDS := &tests.MockDataStore{MockedArtworkQueue: tests.CreateMockArtworkQueueRepo()} - lw := NewWorker(localDS, NewImageStore(GinkgoT().TempDir()), agents.GetAgents(localDS, nil), tests.NewMockFFmpeg("")) + lw := NewWorker(localDS, NewImageStore(GinkgoT().TempDir()), agents.GetAgents(localDS, nil), tests.NewMockFFmpeg(""), &fakeEventBroker{}) runCtx, cancel := context.WithCancel(ctx) done := make(chan error, 1) diff --git a/core/artwork/worker_timing_test.go b/core/artwork/worker_timing_test.go index 30c815c50..159a53081 100644 --- a/core/artwork/worker_timing_test.go +++ b/core/artwork/worker_timing_test.go @@ -47,7 +47,7 @@ func TestArtworkGatePerAgentBreakerIsolation(t *testing.T) { synctest.Test(t, func(t *testing.T) { g := NewWithT(t) w := NewWorker(&tests.MockDataStore{}, NewImageStore(t.TempDir()), - agents.GetAgents(&tests.MockDataStore{}, nil), tests.NewMockFFmpeg("")) + agents.GetAgents(&tests.MockDataStore{}, nil), tests.NewMockFFmpeg(""), &fakeEventBroker{}) fail := func() (io.ReadCloser, string, error) { return nil, "", errors.New("boom") } for range breakerThreshold { diff --git a/tests/mock_artwork_queue_repo.go b/tests/mock_artwork_queue_repo.go index 421e0dd15..500fdf4fd 100644 --- a/tests/mock_artwork_queue_repo.go +++ b/tests/mock_artwork_queue_repo.go @@ -2,6 +2,7 @@ package tests import ( "sort" + "sync" "time" "github.com/navidrome/navidrome/model" @@ -9,6 +10,8 @@ import ( type MockArtworkQueueRepo struct { model.ArtworkQueueRepository + // mu guards Data so the worker's concurrent drain can hit this mock race-free. + mu sync.Mutex Data map[string]model.ArtworkQueueItem // keyed by iaKey(kind, id, imageType) Err error // ItemArtworkSource, when set, backs EnqueueStaleAbsent with real item_artwork state. @@ -22,6 +25,8 @@ func CreateMockArtworkQueueRepo() *MockArtworkQueueRepo { } func (m *MockArtworkQueueRepo) Enqueue(items ...model.ArtworkQueueItem) error { + m.mu.Lock() + defer m.mu.Unlock() if m.Err != nil { return m.Err } @@ -47,6 +52,8 @@ func (m *MockArtworkQueueRepo) Enqueue(items ...model.ArtworkQueueItem) error { } func (m *MockArtworkQueueRepo) DequeueBatch(n int) ([]model.ArtworkQueueItem, error) { + m.mu.Lock() + defer m.mu.Unlock() if m.Err != nil { return nil, m.Err } @@ -70,6 +77,8 @@ func (m *MockArtworkQueueRepo) DequeueBatch(n int) ([]model.ArtworkQueueItem, er } func (m *MockArtworkQueueRepo) MarkFailed(kind, id, imageType string, retryAt time.Time) error { + m.mu.Lock() + defer m.mu.Unlock() if m.Err != nil { return m.Err } @@ -85,6 +94,8 @@ func (m *MockArtworkQueueRepo) MarkFailed(kind, id, imageType string, retryAt ti } func (m *MockArtworkQueueRepo) MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time) error { + m.mu.Lock() + defer m.mu.Unlock() if m.Err != nil { return m.Err } @@ -98,6 +109,8 @@ func (m *MockArtworkQueueRepo) MarkFailedIfUnchanged(kind, id, imageType string, } func (m *MockArtworkQueueRepo) Delete(kind, id, imageType string) error { + m.mu.Lock() + defer m.mu.Unlock() if m.Err != nil { return m.Err } @@ -106,6 +119,8 @@ func (m *MockArtworkQueueRepo) Delete(kind, id, imageType string) error { } func (m *MockArtworkQueueRepo) DeleteIfUnchanged(kind, id, imageType string, retryAt time.Time) error { + m.mu.Lock() + defer m.mu.Unlock() if m.Err != nil { return m.Err } @@ -117,6 +132,8 @@ func (m *MockArtworkQueueRepo) DeleteIfUnchanged(kind, id, imageType string, ret } func (m *MockArtworkQueueRepo) PurgeDangling() (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() if m.Err != nil { return 0, m.Err } @@ -135,6 +152,8 @@ func (m *MockArtworkQueueRepo) PurgeDangling() (int64, error) { } func (m *MockArtworkQueueRepo) Count() (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() if m.Err != nil { return 0, m.Err } @@ -142,6 +161,8 @@ func (m *MockArtworkQueueRepo) Count() (int64, error) { } func (m *MockArtworkQueueRepo) EnqueueStaleAbsent(kind string, attemptedBefore time.Time) (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() if m.Err != nil || m.ItemArtworkSource == nil { return 0, m.Err } diff --git a/tests/mock_artwork_repo.go b/tests/mock_artwork_repo.go index 9e9f84272..aa873e7c6 100644 --- a/tests/mock_artwork_repo.go +++ b/tests/mock_artwork_repo.go @@ -1,6 +1,7 @@ package tests import ( + "sync" "time" "github.com/navidrome/navidrome/model" @@ -8,6 +9,8 @@ import ( type MockArtworkRepo struct { model.ArtworkRepository + // mu guards the maps so the worker's concurrent drain can hit this mock race-free. + mu sync.Mutex Data map[string]model.Artwork ItemData map[string]model.ItemArtwork // keyed by iaKey(kind, id, imageType) OrphanHashes []string @@ -23,6 +26,8 @@ func CreateMockArtworkRepo() *MockArtworkRepo { func iaKey(kind, id, imageType string) string { return kind + "|" + id + "|" + imageType } func (m *MockArtworkRepo) GetImage(hash string) (*model.Artwork, error) { + m.mu.Lock() + defer m.mu.Unlock() if m.Err != nil { return nil, m.Err } @@ -33,6 +38,8 @@ func (m *MockArtworkRepo) GetImage(hash string) (*model.Artwork, error) { } func (m *MockArtworkRepo) PutImage(a *model.Artwork) error { + m.mu.Lock() + defer m.mu.Unlock() if m.Err != nil { return m.Err } @@ -43,6 +50,8 @@ func (m *MockArtworkRepo) PutImage(a *model.Artwork) error { } func (m *MockArtworkRepo) GetImages(hashes []string) (map[string]model.Artwork, error) { + m.mu.Lock() + defer m.mu.Unlock() if m.Err != nil { return nil, m.Err } @@ -56,10 +65,14 @@ func (m *MockArtworkRepo) GetImages(hashes []string) (map[string]model.Artwork, } func (m *MockArtworkRepo) GetOrphanHashes(createdBefore time.Time) ([]string, error) { + m.mu.Lock() + defer m.mu.Unlock() return m.OrphanHashes, m.Err } func (m *MockArtworkRepo) GetAllMimes() (map[string]string, error) { + m.mu.Lock() + defer m.mu.Unlock() if m.Err != nil { return nil, m.Err } @@ -71,6 +84,8 @@ func (m *MockArtworkRepo) GetAllMimes() (map[string]string, error) { } func (m *MockArtworkRepo) PurgeDanglingItemArtwork() (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() if m.Err != nil { return 0, m.Err } @@ -90,6 +105,8 @@ func (m *MockArtworkRepo) PurgeDanglingItemArtwork() (int64, error) { } func (m *MockArtworkRepo) DeleteOrphans(createdBefore time.Time, hashes []string) error { + m.mu.Lock() + defer m.mu.Unlock() if m.Err != nil { return m.Err } @@ -116,6 +133,8 @@ func (m *MockArtworkRepo) referenced(hash string) bool { } func (m *MockArtworkRepo) GetItemArtwork(kind, id, imageType string) (*model.ItemArtwork, error) { + m.mu.Lock() + defer m.mu.Unlock() if m.Err != nil { return nil, m.Err } @@ -126,6 +145,8 @@ func (m *MockArtworkRepo) GetItemArtwork(kind, id, imageType string) (*model.Ite } func (m *MockArtworkRepo) PutItemArtwork(ia *model.ItemArtwork) error { + m.mu.Lock() + defer m.mu.Unlock() if m.Err != nil { return m.Err } @@ -141,6 +162,8 @@ func (m *MockArtworkRepo) PutItemArtwork(ia *model.ItemArtwork) error { } func (m *MockArtworkRepo) DeleteForItem(kind, id string) error { + m.mu.Lock() + defer m.mu.Unlock() if m.Err != nil { return m.Err } @@ -153,6 +176,8 @@ func (m *MockArtworkRepo) DeleteForItem(kind, id string) error { } func (m *MockArtworkRepo) DeleteForItems(kind string, ids []string) error { + m.mu.Lock() + defer m.mu.Unlock() if m.Err != nil { return m.Err } @@ -169,6 +194,8 @@ func (m *MockArtworkRepo) DeleteForItems(kind string, ids []string) error { } func (m *MockArtworkRepo) GetInfoForItems(kind string, ids []string) (map[string]model.ItemArtworkInfo, error) { + m.mu.Lock() + defer m.mu.Unlock() if m.Err != nil { return nil, m.Err } From 3a9dadfe347d408c7784fc11ec8150cf4122a402 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 20:57:08 -0400 Subject: [PATCH 60/87] fix(artwork): broadcast refresh for stale-found artwork too --- core/artwork/worker.go | 3 ++- core/artwork/worker_test.go | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/core/artwork/worker.go b/core/artwork/worker.go index 3261f35a7..a71495441 100644 --- a/core/artwork/worker.go +++ b/core/artwork/worker.go @@ -143,7 +143,8 @@ func (w *Worker) drain(ctx context.Context, concurrency int) (int, error) { defer wg.Done() defer func() { <-sem }() defer w.release(it) - if w.process(ctx, it) == outcomeFound { + // foundStale also wrote a served state row, so it must refresh the UI too. + if out := w.process(ctx, it); out == outcomeFound || out == outcomeFoundStale { foundMu.Lock() found = append(found, it) foundMu.Unlock() diff --git a/core/artwork/worker_test.go b/core/artwork/worker_test.go index 86a9d37a2..97bbc41c8 100644 --- a/core/artwork/worker_test.go +++ b/core/artwork/worker_test.go @@ -222,6 +222,10 @@ var _ = Describe("Worker", func() { ia, err := artRepo.GetItemArtwork("al", "alstale", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(ia.Source).To(Equal("folder"), "the fallback art is served meanwhile") + + evts := broker.getEvents() + Expect(evts).To(HaveLen(1), "the served fallback art must live-refresh the UI") + Expect(evts[0].(*events.RefreshResource).Data(evts[0])).To(ContainSubstring("alstale")) }) It("keeps a row re-enqueued between dequeue and delete", func() { From 313998fd65a975e88c095ef0abe45c949dc3f757 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 21:19:22 -0400 Subject: [PATCH 61/87] feat(artwork): state-backed serving path with provisional read-through --- core/artwork/agent_images.go | 6 + core/artwork/reader_resized.go | 124 +---------- core/artwork/resize.go | 147 +++++++++++++ core/artwork/resolve.go | 35 +++- core/artwork/serving.go | 368 +++++++++++++++++++++++++++++++++ core/artwork/serving_test.go | 323 +++++++++++++++++++++++++++++ core/artwork/wire_providers.go | 1 + 7 files changed, 874 insertions(+), 130 deletions(-) create mode 100644 core/artwork/resize.go create mode 100644 core/artwork/serving.go create mode 100644 core/artwork/serving_test.go diff --git a/core/artwork/agent_images.go b/core/artwork/agent_images.go index 659b1c8c2..029235830 100644 --- a/core/artwork/agent_images.go +++ b/core/artwork/agent_images.go @@ -18,6 +18,12 @@ func passthroughGate(_ string, f func() (io.ReadCloser, string, error)) (io.Read return f() } +// denyGate refuses every external fetch with a definitive not-found, so local-only +// resolution never runs a network step even if an external branch is reached. +func denyGate(_ string, _ func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { + return nil, "", model.ErrNotFound +} + // bestImageURL returns the largest-Size image URL, skipping empty or unparseable // URLs; nil when none qualifies. func bestImageURL(imgs []agents.ExternalImage) *url.URL { diff --git a/core/artwork/reader_resized.go b/core/artwork/reader_resized.go index cd16cbada..7d920e790 100644 --- a/core/artwork/reader_resized.go +++ b/core/artwork/reader_resized.go @@ -1,46 +1,16 @@ package artwork import ( - "bytes" "context" "fmt" - "image" - "image/draw" - "image/jpeg" - "image/png" "io" - "sync" "time" - "github.com/gen2brain/webp" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - xdraw "golang.org/x/image/draw" ) -func init() { - conf.AddHook(func() { - // gen2brain/webp selects native (purego/libwebp) vs WASM in its own - // package init() and exposes the result only via webp.Dynamic(); there is - // no runtime way to switch back. On 32-bit ARM/x86 the purego callback path - // crashes (issue #5597), so those builds must be compiled with the - // "nodynamic" tag (see Dockerfile), which makes webp.Dynamic() report an - // error here and forces the safe WASM path. - if err := webp.Dynamic(); err != nil { - log.Debug("Using WASM WebP encoder/decoder", "reason", err) - } else { - log.Debug("Using native libwebp for WebP encoding/decoding") - } - }) -} - -var bufPool = sync.Pool{ - New: func() any { - return new(bytes.Buffer) - }, -} - type resizedArtworkReader struct { artID model.ArtworkID cacheKey string @@ -113,97 +83,5 @@ func (a *resizedArtworkReader) resizeImage(ctx context.Context, reader io.Reader if err != nil { return nil, 0, fmt.Errorf("reading image data: %w", err) } - - // Preserve animation for animated images - if isAnimatedGIF(data) { - if a.a.ffmpeg.IsAvailable() { - // Animated GIF: convert to animated WebP via ffmpeg (with optional resize) - r, err := a.a.ffmpeg.ConvertAnimatedImage(ctx, bytes.NewReader(data), a.size, conf.Server.CoverArtQuality) - if err == nil { - return r, 0, nil - } - log.Warn(ctx, "Could not convert animated GIF, falling back to static", err) - } - } else if isAnimatedWebP(data) || isAnimatedPNG(data) { - // Animated WebP/APNG: return original as-is (ffmpeg can't re-encode these) - return bytes.NewReader(data), 0, nil - } - - return resizeStaticImage(data, a.size, a.square) -} - -// toFastScaleType converts images whose concrete type has no optimized scaler -// in x/image/draw (e.g. *image.NYCbCrA from WebP, *image.Paletted from indexed -// PNGs) into *image.RGBA, which has a fast path. Without this, CatmullRom.Scale -// falls back to a generic per-pixel At()/RGBA() loop that is several times -// slower. Fast-path types are returned unchanged. -func toFastScaleType(img image.Image) image.Image { - switch img.(type) { - case *image.RGBA, *image.NRGBA, *image.Gray, *image.YCbCr: - return img - default: - rgba := image.NewRGBA(img.Bounds()) - draw.Draw(rgba, rgba.Bounds(), img, img.Bounds().Min, draw.Src) - return rgba - } -} - -func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, error) { - original, format, err := image.Decode(bytes.NewReader(data)) - if err != nil { - return nil, 0, err - } - - bounds := original.Bounds() - originalSize := max(bounds.Max.X, bounds.Max.Y) - - // Clamp size to original dimensions - upscaling wastes resources and adds no information - if size > originalSize { - size = originalSize - } - - if originalSize <= size && !square { - return nil, originalSize, nil - } - - // Calculate aspect-fit dimensions - srcW, srcH := bounds.Dx(), bounds.Dy() - scale := float64(size) / float64(max(srcW, srcH)) - dstW := int(float64(srcW) * scale) - dstH := int(float64(srcH) * scale) - - var dst *image.NRGBA - var dstRect image.Rectangle - if square { - // Square canvas with image centered (transparent padding via zero-initialized NRGBA) - dst = image.NewNRGBA(image.Rect(0, 0, size, size)) - offsetX := (size - dstW) / 2 - offsetY := (size - dstH) / 2 - dstRect = image.Rect(offsetX, offsetY, offsetX+dstW, offsetY+dstH) - } else { - // Tight-fit canvas - dst = image.NewNRGBA(image.Rect(0, 0, dstW, dstH)) - dstRect = dst.Bounds() - } - original = toFastScaleType(original) - xdraw.CatmullRom.Scale(dst, dstRect, original, bounds, draw.Src, nil) - - buf := bufPool.Get().(*bytes.Buffer) - buf.Reset() - if conf.Server.EnableWebPEncoding { - err = webp.Encode(buf, dst, webp.Options{Quality: conf.Server.CoverArtQuality}) - } else if format == "png" || square { - err = png.Encode(buf, dst) - } else { - err = jpeg.Encode(buf, dst, &jpeg.Options{Quality: conf.Server.CoverArtQuality}) - } - if err != nil { - bufPool.Put(buf) - return nil, originalSize, err - } - // Copy bytes before returning buffer to pool (pool may reuse the buffer) - encoded := make([]byte, buf.Len()) - copy(encoded, buf.Bytes()) - bufPool.Put(buf) - return bytes.NewReader(encoded), originalSize, nil + return resizeImageData(ctx, a.a.ffmpeg, data, a.size, a.square) } diff --git a/core/artwork/resize.go b/core/artwork/resize.go new file mode 100644 index 000000000..e213cf5d0 --- /dev/null +++ b/core/artwork/resize.go @@ -0,0 +1,147 @@ +package artwork + +import ( + "bytes" + "context" + "fmt" + "image" + "image/draw" + "image/jpeg" + "image/png" + "io" + "sync" + + "github.com/gen2brain/webp" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/ffmpeg" + "github.com/navidrome/navidrome/log" + xdraw "golang.org/x/image/draw" +) + +func init() { + conf.AddHook(func() { + // gen2brain/webp selects native (purego/libwebp) vs WASM in its own + // package init() and exposes the result only via webp.Dynamic(); there is + // no runtime way to switch back. On 32-bit ARM/x86 the purego callback path + // crashes (issue #5597), so those builds must be compiled with the + // "nodynamic" tag (see Dockerfile), which makes webp.Dynamic() report an + // error here and forces the safe WASM path. + if err := webp.Dynamic(); err != nil { + log.Debug("Using WASM WebP encoder/decoder", "reason", err) + } else { + log.Debug("Using native libwebp for WebP encoding/decoding") + } + }) +} + +var bufPool = sync.Pool{ + New: func() any { + return new(bytes.Buffer) + }, +} + +// resizeImageData resizes raw image bytes to fit size, preserving animation where +// possible. A nil reader means the image was already within bounds (no resize needed). +func resizeImageData(ctx context.Context, ffm ffmpeg.FFmpeg, data []byte, size int, square bool) (io.Reader, int, error) { + // Preserve animation for animated images + if isAnimatedGIF(data) { + if ffm.IsAvailable() { + // Animated GIF: convert to animated WebP via ffmpeg (with optional resize) + r, err := ffm.ConvertAnimatedImage(ctx, bytes.NewReader(data), size, conf.Server.CoverArtQuality) + if err == nil { + return r, 0, nil + } + log.Warn(ctx, "Could not convert animated GIF, falling back to static", err) + } + } else if isAnimatedWebP(data) || isAnimatedPNG(data) { + // Animated WebP/APNG: return original as-is (ffmpeg can't re-encode these) + return bytes.NewReader(data), 0, nil + } + + return resizeStaticImage(data, size, square) +} + +// toFastScaleType converts images whose concrete type has no optimized scaler +// in x/image/draw (e.g. *image.NYCbCrA from WebP, *image.Paletted from indexed +// PNGs) into *image.RGBA, which has a fast path. Without this, CatmullRom.Scale +// falls back to a generic per-pixel At()/RGBA() loop that is several times +// slower. Fast-path types are returned unchanged. +func toFastScaleType(img image.Image) image.Image { + switch img.(type) { + case *image.RGBA, *image.NRGBA, *image.Gray, *image.YCbCr: + return img + default: + rgba := image.NewRGBA(img.Bounds()) + draw.Draw(rgba, rgba.Bounds(), img, img.Bounds().Min, draw.Src) + return rgba + } +} + +func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, error) { + original, format, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return nil, 0, err + } + + bounds := original.Bounds() + originalSize := max(bounds.Max.X, bounds.Max.Y) + + // Clamp size to original dimensions - upscaling wastes resources and adds no information + if size > originalSize { + size = originalSize + } + + if originalSize <= size && !square { + return nil, originalSize, nil + } + + // Calculate aspect-fit dimensions + srcW, srcH := bounds.Dx(), bounds.Dy() + scale := float64(size) / float64(max(srcW, srcH)) + dstW := int(float64(srcW) * scale) + dstH := int(float64(srcH) * scale) + + var dst *image.NRGBA + var dstRect image.Rectangle + if square { + // Square canvas with image centered (transparent padding via zero-initialized NRGBA) + dst = image.NewNRGBA(image.Rect(0, 0, size, size)) + offsetX := (size - dstW) / 2 + offsetY := (size - dstH) / 2 + dstRect = image.Rect(offsetX, offsetY, offsetX+dstW, offsetY+dstH) + } else { + // Tight-fit canvas + dst = image.NewNRGBA(image.Rect(0, 0, dstW, dstH)) + dstRect = dst.Bounds() + } + original = toFastScaleType(original) + xdraw.CatmullRom.Scale(dst, dstRect, original, bounds, draw.Src, nil) + + buf := bufPool.Get().(*bytes.Buffer) + buf.Reset() + if conf.Server.EnableWebPEncoding { + err = webp.Encode(buf, dst, webp.Options{Quality: conf.Server.CoverArtQuality}) + } else if format == "png" || square { + err = png.Encode(buf, dst) + } else { + err = jpeg.Encode(buf, dst, &jpeg.Options{Quality: conf.Server.CoverArtQuality}) + } + if err != nil { + bufPool.Put(buf) + return nil, originalSize, err + } + // Copy bytes before returning buffer to pool (pool may reuse the buffer) + encoded := make([]byte, buf.Len()) + copy(encoded, buf.Bytes()) + bufPool.Put(buf) + return bytes.NewReader(encoded), originalSize, nil +} + +// formatQualityTag folds the encoder config (WebP toggle + quality) into a cache-key +// fragment, so flipping either setting invalidates previously-encoded sized artwork. +func formatQualityTag() string { + if conf.Server.EnableWebPEncoding { + return fmt.Sprintf("webp%d", conf.Server.CoverArtQuality) + } + return fmt.Sprintf("q%d", conf.Server.CoverArtQuality) +} diff --git a/core/artwork/resolve.go b/core/artwork/resolve.go index 22ad3de52..5caab5332 100644 --- a/core/artwork/resolve.go +++ b/core/artwork/resolve.go @@ -37,16 +37,26 @@ type resolution struct { // resolveItem walks the kind's priority chain and returns the first hit. func resolveItem(ctx context.Context, ds model.DataStore, ag *agents.Agents, ffmpeg ffmpeg.FFmpeg, item model.ArtworkQueueItem, gate gateFunc) (resolution, error) { + return resolveItemMode(ctx, ds, ag, ffmpeg, item, gate, false) +} + +// resolveItemLocal resolves using only local sources for the serving path's provisional +// read-through: external steps are skipped and the worker-built playlist grid is not assembled. +func resolveItemLocal(ctx context.Context, ds model.DataStore, ffmpeg ffmpeg.FFmpeg, item model.ArtworkQueueItem) (resolution, error) { + return resolveItemMode(ctx, ds, nil, ffmpeg, item, denyGate, true) +} + +func resolveItemMode(ctx context.Context, ds model.DataStore, ag *agents.Agents, ffmpeg ffmpeg.FFmpeg, item model.ArtworkQueueItem, gate gateFunc, localOnly bool) (resolution, error) { if gate == nil { gate = passthroughGate } switch item.ItemKind { case "al": - return resolveAlbum(ctx, ds, ag, ffmpeg, item.ItemID, gate) + return resolveAlbum(ctx, ds, ag, ffmpeg, item.ItemID, gate, localOnly) case "ar": - return resolveArtist(ctx, ds, ag, ffmpeg, item.ItemID, gate) + return resolveArtist(ctx, ds, ag, ffmpeg, item.ItemID, gate, localOnly) case "pl": - return resolvePlaylist(ctx, ds, ag, ffmpeg, item.ItemID, gate) + return resolvePlaylist(ctx, ds, ag, ffmpeg, item.ItemID, gate, localOnly) case "ra": return resolveRadio(ctx, ds, item.ItemID) case "mf": @@ -58,7 +68,7 @@ func resolveItem(ctx context.Context, ds model.DataStore, ag *agents.Agents, ffm // resolveAlbum ports the folder/embedded/external selection from // reader_album.go, walking conf.Server.CoverArtPriority. -func resolveAlbum(ctx context.Context, ds model.DataStore, ag *agents.Agents, ffm ffmpeg.FFmpeg, albumID string, gate gateFunc) (resolution, error) { +func resolveAlbum(ctx context.Context, ds model.DataStore, ag *agents.Agents, ffm ffmpeg.FFmpeg, albumID string, gate gateFunc, localOnly bool) (resolution, error) { al, err := ds.Album(ctx).Get(albumID) if err != nil { return resolution{}, err @@ -82,6 +92,9 @@ func resolveAlbum(ctx context.Context, ds model.DataStore, ag *agents.Agents, ff return res, nil } case pattern == "external": + if localOnly { + continue + } if r, name, isErr := fetchAlbumImage(ctx, ag, gate, *al); r != nil { return resolution{reader: r, source: "external:" + name}, nil } else if isErr { @@ -99,7 +112,7 @@ func resolveAlbum(ctx context.Context, ds model.DataStore, ag *agents.Agents, ff // resolveArtist ports the upload/folder/external selection from // reader_artist.go: upload always wins, then conf.Server.ArtistArtPriority. -func resolveArtist(ctx context.Context, ds model.DataStore, ag *agents.Agents, ffm ffmpeg.FFmpeg, artistID string, gate gateFunc) (resolution, error) { +func resolveArtist(ctx context.Context, ds model.DataStore, ag *agents.Agents, ffm ffmpeg.FFmpeg, artistID string, gate gateFunc, localOnly bool) (resolution, error) { ar, err := ds.Artist(ctx).Get(artistID) if err != nil { return resolution{}, err @@ -139,6 +152,9 @@ func resolveArtist(ctx context.Context, ds model.DataStore, ag *agents.Agents, f pattern = strings.TrimSpace(pattern) switch { case pattern == "external": + if localOnly { + continue + } if r, name, isErr := fetchArtistImage(ctx, ag, gate, *ar); r != nil { return resolution{reader: r, source: "external:" + name}, nil } else if isErr { @@ -172,7 +188,7 @@ func resolveArtist(ctx context.Context, ds model.DataStore, ag *agents.Agents, f // resolvePlaylist ports reader_playlist.go's chain: uploaded image, sidecar, // ExternalImageURL, then the generated 2x2 grid sourced through resolveAlbum. -func resolvePlaylist(ctx context.Context, ds model.DataStore, ag *agents.Agents, ffm ffmpeg.FFmpeg, playlistID string, gate gateFunc) (resolution, error) { +func resolvePlaylist(ctx context.Context, ds model.DataStore, ag *agents.Agents, ffm ffmpeg.FFmpeg, playlistID string, gate gateFunc, localOnly bool) (resolution, error) { pl, err := ds.Playlist(ctx).Get(playlistID) if err != nil { return resolution{}, err @@ -185,6 +201,11 @@ func resolvePlaylist(ctx context.Context, ds model.DataStore, ag *agents.Agents, if res, ok := resolveLocalFile(findPlaylistSidecarPath(ctx, pl.Path), "folder"); ok { return res, nil } + if localOnly { + // The ExternalImageURL step and the 2x2 grid are worker-only; a request must + // not fetch remotely nor sample album art synchronously. + return resolution{}, nil + } if res, ok, isErr := resolveExternalStep(gate, "m3u", fromPlaylistExternalSource(ctx, *pl)); ok { return res, nil } else if isErr { @@ -199,7 +220,7 @@ func resolvePlaylist(ctx context.Context, ds model.DataStore, ag *agents.Agents, var tiles []image.Image var tileErr error // first internal (non-external) tile failure, e.g. album deleted mid-flight for _, albumID := range albumIDs { - res, err := resolveAlbum(ctx, ds, ag, ffm, albumID, gate) + res, err := resolveAlbum(ctx, ds, ag, ffm, albumID, gate, false) if err != nil { if tileErr == nil { tileErr = err diff --git a/core/artwork/serving.go b/core/artwork/serving.go new file mode 100644 index 000000000..7bfc46c81 --- /dev/null +++ b/core/artwork/serving.go @@ -0,0 +1,368 @@ +package artwork + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/ffmpeg" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/resources" + "github.com/navidrome/navidrome/utils/cache" +) + +// errStaleSource signals that a backing file's mtime no longer matches the state +// row's RefMtime: the stored hash may be stale, so the load is aborted (dangling). +var errStaleSource = errors.New("artwork: source file changed since resolution") + +// Image is one servable artwork response. +type Image struct { + io.ReadCloser + Hash string // "" for placeholders + LastUpdated time.Time // zero for placeholders + Placeholder bool +} + +type Service interface { + // Get serves resolved/provisional artwork; ErrUnavailable or model.ErrNotFound when + // there is nothing to serve (absent, pending, dangling) — caller picks placeholder vs 404. + Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) + // GetOrPlaceholder parses a raw id token (raw entity ids accepted, as today) and falls + // back to the kind's placeholder image (never resized, Placeholder=true). + GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (*Image, error) +} + +func NewService(ds model.DataStore, cache cache.FileCache, store *ImageStore, ffm ffmpeg.FFmpeg) Service { + return &service{ds: ds, cache: cache, store: store, ffmpeg: ffm} +} + +type service struct { + ds model.DataStore + cache cache.FileCache + store *ImageStore + ffmpeg ffmpeg.FFmpeg +} + +func (s *service) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (*Image, error) { + artID, err := s.parseArtworkID(ctx, id) + var img *Image + if err == nil { + img, err = s.Get(ctx, artID, size, square) + } + if errors.Is(err, ErrUnavailable) || errors.Is(err, model.ErrNotFound) { + return s.placeholder(artID.Kind), nil + } + return img, err +} + +func (s *service) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) { + if artID.ID == "" { + return nil, ErrUnavailable + } + switch artID.Kind { + case model.KindDiscArtwork: + return s.serveDisc(ctx, artID, size, square) + case model.KindMediaFileArtwork: + return s.serveMediaFile(ctx, artID, size, square) + default: + return s.serveEntity(ctx, artID, size, square) + } +} + +// serveEntity serves an entity whose state the worker owns (album/artist/playlist/radio): +// found row serves its hash, absent row is unavailable, missing row reads through provisionally. +func (s *service) serveEntity(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) { + ia, err := s.ds.Artwork(ctx).GetItemArtwork(artID.Kind.Prefix(), artID.ID, model.ImageTypePrimary) + switch { + case errors.Is(err, model.ErrNotFound): + return s.provisional(ctx, artID, size, square) + case err != nil: + return nil, err + case ia.Hash == "": + return nil, ErrUnavailable + default: + return s.serveHash(ctx, artID, ia, size, square) + } +} + +// serveHash serves the bytes of a found state row: full-size streams the original, sized +// goes through the resize cache. A mismatch/open error is dangling (a warm cache still serves). +func (s *service) serveHash(ctx context.Context, artID model.ArtworkID, ia *model.ItemArtwork, size int, square bool) (*Image, error) { + art, err := s.ds.Artwork(ctx).GetImage(ia.Hash) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + return s.dangling(ctx, artID) + } + return nil, err + } + + if size == 0 && !square { + rc, err := s.openOriginal(ia, art.Mime) + if err != nil { + return s.dangling(ctx, artID) + } + return &Image{ReadCloser: rc, Hash: ia.Hash, LastUpdated: ia.UpdatedAt}, nil + } + + item := &resizedItem{ + hash: ia.Hash, + size: size, + square: square, + lastUpdate: ia.UpdatedAt, + ffmpeg: s.ffmpeg, + open: func() (io.ReadCloser, error) { return s.openOriginal(ia, art.Mime) }, + } + stream, err := s.cache.Get(ctx, item) + if err != nil { + if errors.Is(err, context.Canceled) { + return nil, err + } + return s.dangling(ctx, artID) + } + return &Image{ReadCloser: stream, Hash: ia.Hash, LastUpdated: ia.UpdatedAt}, nil +} + +// openOriginal opens the full-resolution bytes for a found state row, enforcing the +// mtime invariant: bytes are never served under a hash they no longer match. +func (s *service) openOriginal(ia *model.ItemArtwork, mime string) (io.ReadCloser, error) { + if isFileBacked(ia.Source) { + f, err := os.Open(ia.SourcePath) + if err != nil { + return nil, err + } + info, err := f.Stat() + if err != nil { + f.Close() + return nil, err + } + if ia.RefMtime != 0 && info.ModTime().Unix() != ia.RefMtime { + f.Close() + return nil, errStaleSource + } + return f, nil + } + // Store-backed (embedded/external/generated): the bytes live in the content-addressed + // store, but an embedded source still carries the audio file's mtime to detect edits. + if ia.SourcePath != "" && ia.RefMtime != 0 { + info, err := os.Stat(ia.SourcePath) + if err != nil { + return nil, err + } + if info.ModTime().Unix() != ia.RefMtime { + return nil, errStaleSource + } + } + return s.store.Open(ia.Hash, mime) +} + +// provisional does a local-only read-through for an entity with no state row: it enqueues +// the worker (Bump) and serves any local bytes immediately, never writing a state row. +func (s *service) provisional(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) { + item := model.ArtworkQueueItem{ItemKind: artID.Kind.Prefix(), ItemID: artID.ID, ImageType: model.ImageTypePrimary} + res, err := resolveItemLocal(ctx, s.ds, s.ffmpeg, item) + if err != nil { + return nil, err + } + s.enqueue(ctx, artID, model.ArtworkPriorityBump) + return s.serveResolution(ctx, res, size, square) +} + +// serveResolution turns a local resolution's bytes into a servable Image (byte-hash +// only, no decode). A resolution with no reader is unavailable. +func (s *service) serveResolution(ctx context.Context, res resolution, size int, square bool) (*Image, error) { + if res.reader == nil { + return nil, ErrUnavailable + } + defer res.reader.Close() + data, err := readCapped(res.reader) + if err != nil { + return nil, ErrUnavailable + } + hash, err := HashImage(bytes.NewReader(data)) + if err != nil { + return nil, ErrUnavailable + } + return s.serveBytes(ctx, hash, data, unixMtime(res.refMtime), size, square) +} + +// serveBytes serves in-memory bytes: full-size directly, sized through the resize +// cache keyed by the byte-hash (so it lines up with the worker's eventual store entry). +func (s *service) serveBytes(ctx context.Context, hash string, data []byte, lastUpdate time.Time, size int, square bool) (*Image, error) { + if size == 0 && !square { + return &Image{ReadCloser: io.NopCloser(bytes.NewReader(data)), Hash: hash, LastUpdated: lastUpdate}, nil + } + item := &resizedItem{ + hash: hash, + size: size, + square: square, + lastUpdate: lastUpdate, + ffmpeg: s.ffmpeg, + open: func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(data)), nil }, + } + stream, err := s.cache.Get(ctx, item) + if err != nil { + return nil, err + } + return &Image{ReadCloser: stream, Hash: hash, LastUpdated: lastUpdate}, nil +} + +// serveMediaFile serves a track: own found art wins; an absent row delegates to the album; +// a missing row extracts embedded art (if eligible, enqueuing) else delegates without enqueue. +func (s *service) serveMediaFile(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) { + ia, err := s.ds.Artwork(ctx).GetItemArtwork("mf", artID.ID, model.ImageTypePrimary) + switch { + case err == nil && ia.Hash != "": + return s.serveHash(ctx, artID, ia, size, square) + case err == nil: + // absent row → fall through to album delegation + case errors.Is(err, model.ErrNotFound): + // no row → fall through to embedded eligibility / album delegation + default: + return nil, err + } + noRow := errors.Is(err, model.ErrNotFound) + + mf, err := s.ds.MediaFile(ctx).Get(artID.ID) + if err != nil { + return nil, err + } + if noRow && conf.Server.EnableMediaFileCoverArt && mf.HasCoverArt { + return s.provisionalEmbedded(ctx, artID, *mf, size, square) + } + return s.Get(ctx, mf.AlbumCoverArtID(), size, square) +} + +// provisionalEmbedded extracts a track's embedded art for an immediate serve and always +// enqueues the track (Bump) so the worker persists state; it never writes a state row. +func (s *service) provisionalEmbedded(ctx context.Context, artID model.ArtworkID, mf model.MediaFile, size int, square bool) (*Image, error) { + lib, err := loadLibraryView(ctx, s.ds, mf.LibraryID) + if err != nil { + return nil, err + } + res, _ := resolveEmbedded(ctx, lib, s.ffmpeg, mf.Path) + s.enqueue(ctx, artID, model.ArtworkPriorityBump) + return s.serveResolution(ctx, res, size, square) +} + +// serveDisc serves disc-level artwork as a pure provisional read-through: no state rows, +// no enqueue. It tries the disc-folder selection chain and falls back to the album cover. +func (s *service) serveDisc(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) { + dr, err := newDiscArtworkReader(ctx, &artwork{ds: s.ds, ffmpeg: s.ffmpeg}, artID) + if err != nil { + return nil, err + } + funcs := dr.fromDiscArtPriority(ctx, s.ffmpeg, conf.Server.DiscArtPriority) + if r, path, err := selectImageReader(ctx, artID, funcs...); err == nil && r != nil { + defer r.Close() + if data, rerr := readCapped(r); rerr == nil { + if hash, herr := HashImage(bytes.NewReader(data)); herr == nil { + return s.serveBytes(ctx, hash, data, unixMtime(mtimeViaFS(dr.lib.FS, path)), size, square) + } + } + } + albumArtID := model.ArtworkID{Kind: model.KindAlbumArtwork, ID: dr.album.ID} + return s.Get(ctx, albumArtID, size, square) +} + +// dangling enqueues a re-resolution at Scan priority and reports the artwork as +// unavailable, leaving the state row untouched. +func (s *service) dangling(ctx context.Context, artID model.ArtworkID) (*Image, error) { + s.enqueue(ctx, artID, model.ArtworkPriorityScan) + return nil, ErrUnavailable +} + +func (s *service) enqueue(ctx context.Context, artID model.ArtworkID, priority int) { + err := s.ds.ArtworkQueue(ctx).Enqueue(model.ArtworkQueueItem{ + ItemKind: artID.Kind.Prefix(), + ItemID: artID.ID, + ImageType: model.ImageTypePrimary, + Priority: priority, + }) + if err != nil { + log.Warn(ctx, "artwork: could not enqueue re-resolution", "artID", artID, err) + } +} + +func (s *service) placeholder(kind model.Kind) *Image { + path := consts.PlaceholderAlbumArt + if kind == model.KindArtistArtwork { + path = consts.PlaceholderArtistArt + } + r, _ := resources.FS().Open(path) + return &Image{ReadCloser: r, Placeholder: true} +} + +type coverArtIDGetter interface { + CoverArtID() model.ArtworkID +} + +// parseArtworkID ports the legacy getArtworkId: parse the token, and if it is a raw +// entity id, resolve the entity and take its CoverArtID. +func (s *service) parseArtworkID(ctx context.Context, id string) (model.ArtworkID, error) { + if id == "" { + return model.ArtworkID{}, ErrUnavailable + } + if artID, err := model.ParseArtworkID(id); err == nil { + return artID, nil + } + entity, err := model.GetEntityByID(ctx, s.ds, id) + if err != nil { + return model.ArtworkID{}, err + } + if e, ok := entity.(coverArtIDGetter); ok { + return e.CoverArtID(), nil + } + return model.ArtworkID{}, model.ErrNotFound +} + +func unixMtime(mtime int64) time.Time { + if mtime <= 0 { + return time.Time{} + } + return time.Unix(mtime, 0) +} + +// resizedItem is an artworkReader that resizes bytes opened by open() and caches the +// result under a hash-derived key, sharing the image cache with the legacy readers. +type resizedItem struct { + hash string + size int + square bool + lastUpdate time.Time + ffmpeg ffmpeg.FFmpeg + open func() (io.ReadCloser, error) +} + +func (r *resizedItem) Key() string { + return fmt.Sprintf("h-%s.%d.%v.%s", r.hash, r.size, r.square, formatQualityTag()) +} + +func (r *resizedItem) LastUpdated() time.Time { return r.lastUpdate } + +func (r *resizedItem) Reader(ctx context.Context) (io.ReadCloser, string, error) { + orig, err := r.open() + if err != nil { + return nil, "", err + } + defer orig.Close() + data, err := readCapped(orig) + if err != nil { + return nil, "", err + } + resized, _, err := resizeImageData(ctx, r.ffmpeg, data, r.size, r.square) + if err != nil || resized == nil { + // Resize failed or image already within bounds: serve the original bytes. + return io.NopCloser(bytes.NewReader(data)), r.Key(), nil + } + if rc, ok := resized.(io.ReadCloser); ok { + return rc, r.Key(), nil + } + return io.NopCloser(resized), r.Key(), nil +} diff --git a/core/artwork/serving_test.go b/core/artwork/serving_test.go new file mode 100644 index 000000000..529cd5e24 --- /dev/null +++ b/core/artwork/serving_test.go @@ -0,0 +1,323 @@ +package artwork + +import ( + "bytes" + "context" + "image" + "io" + "os" + "path/filepath" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/resources" + "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/cache" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Service", func() { + var ( + ctx context.Context + ds *tests.MockDataStore + artRepo *tests.MockArtworkRepo + queueRepo *tests.MockArtworkQueueRepo + albumRepo *tests.MockAlbumRepo + mfRepo *tests.MockMediaFileRepo + folderRepo *fakeFolderRepo + libRepo *tests.MockLibraryRepo + ffm *tests.MockFFmpeg + store *ImageStore + imgCache cache.FileCache + svc Service + repoRoot string + coverBytes []byte + ) + + primaryKey := func(kind, id string) string { return kind + "|" + id + "|" + model.ImageTypePrimary } + + // seedFoundStore installs a store-backed found state (bytes in the content-addressed + // store, no backing file) and returns the hash. + seedFoundStore := func(kind, id string, imgBytes []byte) string { + hash, err := HashImage(bytes.NewReader(imgBytes)) + Expect(err).ToNot(HaveOccurred()) + Expect(store.Write(hash, "image/jpeg", bytes.NewReader(imgBytes))).To(Succeed()) + Expect(artRepo.PutImage(&model.Artwork{Hash: hash, Mime: "image/jpeg"})).To(Succeed()) + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: kind, ItemID: id, Hash: hash, Source: "external"})).To(Succeed()) + return hash + } + + readAll := func(img *Image) []byte { + GinkgoHelper() + defer img.Close() + data, err := io.ReadAll(img) + Expect(err).ToNot(HaveOccurred()) + return data + } + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + ctx = context.Background() + var err error + repoRoot, err = os.Getwd() + Expect(err).ToNot(HaveOccurred()) + coverBytes, err = os.ReadFile(filepath.Join(repoRoot, "tests/fixtures/artist/an-album/cover.jpg")) + Expect(err).ToNot(HaveOccurred()) + + conf.Server.EnableWebPEncoding = false + conf.Server.CoverArtQuality = 75 + conf.Server.CoverArtPriority = "cover.*" + conf.Server.DiscArtPriority = "cover.*" + conf.Server.CacheFolder = conf.NewDir(GinkgoT().TempDir()) + + artRepo = tests.CreateMockArtworkRepo() + queueRepo = tests.CreateMockArtworkQueueRepo() + albumRepo = tests.CreateMockAlbumRepo() + mfRepo = tests.CreateMockMediaFileRepo() + folderRepo = &fakeFolderRepo{} + libRepo = &tests.MockLibraryRepo{} + libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) + ds = &tests.MockDataStore{ + MockedArtwork: artRepo, + MockedArtworkQueue: queueRepo, + MockedAlbum: albumRepo, + MockedMediaFile: mfRepo, + MockedFolder: folderRepo, + MockedLibrary: libRepo, + } + ffm = tests.NewMockFFmpeg("") + store = NewImageStore(GinkgoT().TempDir()) + imgCache = cache.NewFileCache("ServingTest", "100MB", "images", 0, + func(ctx context.Context, arg cache.Item) (io.Reader, error) { + r, _, err := arg.(artworkReader).Reader(ctx) + return r, err + }) + Eventually(func() bool { return imgCache.Available(ctx) }).Should(BeTrue()) + svc = NewService(ds, imgCache, store, ffm) + }) + + Describe("found state", func() { + It("serves a store-backed found image sized (cache miss resizes, second call is a cache hit)", func() { + seedFoundStore("al", "al1", coverBytes) + + img, err := svc.Get(ctx, model.MustParseArtworkID("al-al1"), 100, false) + Expect(err).ToNot(HaveOccurred()) + resized := readAll(img) + cfg, _, err := image.DecodeConfig(bytes.NewReader(resized)) + Expect(err).ToNot(HaveOccurred()) + Expect(cfg.Width).To(Equal(100)) + + // Delete the store file: a warm resize-cache entry must keep serving without + // ever touching the original (the stale-serve self-heal). + hash, _ := HashImage(bytes.NewReader(coverBytes)) + Expect(store.Remove(hash, "image/jpeg", time.Now().Add(time.Hour))).To(Succeed()) + Eventually(func(g Gomega) { + img2, err := svc.Get(ctx, model.MustParseArtworkID("al-al1"), 100, false) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(readAll(img2)).To(Equal(resized)) + }).Should(Succeed()) + }) + + It("streams a file-backed found image at full size", func() { + dir := GinkgoT().TempDir() + imgPath := filepath.Join(dir, "cover.jpg") + Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed()) + mtime := fileMtime(imgPath) + Expect(artRepo.PutImage(&model.Artwork{Hash: "aaaaaaaaaaaaaaaa", Mime: "image/jpeg"})).To(Succeed()) + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + ItemKind: "al", ItemID: "al2", Hash: "aaaaaaaaaaaaaaaa", + Source: "folder", SourcePath: imgPath, RefMtime: mtime, + })).To(Succeed()) + + img, err := svc.Get(ctx, model.MustParseArtworkID("al-al2"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(readAll(img)).To(Equal(coverBytes)) + }) + + It("treats a full-size mtime mismatch as dangling: unavailable, re-enqueued at Scan, state untouched", func() { + dir := GinkgoT().TempDir() + imgPath := filepath.Join(dir, "cover.jpg") + Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed()) + Expect(artRepo.PutImage(&model.Artwork{Hash: "bbbbbbbbbbbbbbbb", Mime: "image/jpeg"})).To(Succeed()) + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + ItemKind: "al", ItemID: "al3", Hash: "bbbbbbbbbbbbbbbb", + Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999, + })).To(Succeed()) + + _, err := svc.Get(ctx, model.MustParseArtworkID("al-al3"), 0, false) + Expect(err).To(MatchError(ErrUnavailable)) + Expect(queueRepo.Data[primaryKey("al", "al3")].Priority).To(Equal(model.ArtworkPriorityScan)) + ia, err := artRepo.GetItemArtwork("al", "al3", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Hash).To(Equal("bbbbbbbbbbbbbbbb")) + }) + + It("enforces the mtime rule on the sized (loader) path too", func() { + dir := GinkgoT().TempDir() + imgPath := filepath.Join(dir, "cover.jpg") + Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed()) + Expect(artRepo.PutImage(&model.Artwork{Hash: "cccccccccccccccc", Mime: "image/jpeg"})).To(Succeed()) + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + ItemKind: "al", ItemID: "al3b", Hash: "cccccccccccccccc", + Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999, + })).To(Succeed()) + + _, err := svc.Get(ctx, model.MustParseArtworkID("al-al3b"), 100, false) + Expect(err).To(MatchError(ErrUnavailable)) + Expect(queueRepo.Data[primaryKey("al", "al3b")].Priority).To(Equal(model.ArtworkPriorityScan)) + }) + + It("returns ErrUnavailable for an absent state without enqueuing", func() { + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "al4"})).To(Succeed()) + + _, err := svc.Get(ctx, model.MustParseArtworkID("al-al4"), 0, false) + Expect(err).To(MatchError(ErrUnavailable)) + Expect(queueRepo.Data).To(BeEmpty()) + }) + }) + + Describe("provisional read-through", func() { + It("serves local folder art, enqueues a Bump, and writes no state row", func() { + folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}} + albumRepo.SetData(model.Albums{{ID: "al5", Name: "Album", FolderIDs: []string{"f1"}}}) + + img, err := svc.Get(ctx, model.MustParseArtworkID("al-al5"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(readAll(img)).To(Equal(coverBytes)) + + Expect(queueRepo.Data[primaryKey("al", "al5")].Priority).To(Equal(model.ArtworkPriorityBump)) + _, err = artRepo.GetItemArtwork("al", "al5", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("returns ErrUnavailable and enqueues a Bump when nothing local resolves", func() { + folderRepo.result = nil + albumRepo.SetData(model.Albums{{ID: "al6", Name: "Album"}}) + + _, err := svc.Get(ctx, model.MustParseArtworkID("al-al6"), 0, false) + Expect(err).To(MatchError(ErrUnavailable)) + Expect(queueRepo.Data[primaryKey("al", "al6")].Priority).To(Equal(model.ArtworkPriorityBump)) + _, err = artRepo.GetItemArtwork("al", "al6", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + + Describe("media file", func() { + It("serves a track's own found art", func() { + seedFoundStore("mf", "mf1", coverBytes) + + img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf1"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(readAll(img)).To(Equal(coverBytes)) + }) + + It("delegates to the album when the track's state is absent", func() { + seedFoundStore("al", "albm", coverBytes) + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "mf", ItemID: "mf2"})).To(Succeed()) + mfRepo.SetData(model.MediaFiles{{ID: "mf2", AlbumID: "albm"}}) + + img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf2"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(readAll(img)).To(Equal(coverBytes)) + _, mfEnq := queueRepo.Data[primaryKey("mf", "mf2")] + Expect(mfEnq).To(BeFalse()) + }) + + It("delegates to the album (no enqueue) when the track is not embedded-eligible", func() { + conf.Server.EnableMediaFileCoverArt = true + seedFoundStore("al", "albn", coverBytes) + mfRepo.SetData(model.MediaFiles{{ID: "mf3", AlbumID: "albn", HasCoverArt: false}}) + + img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf3"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(readAll(img)).To(Equal(coverBytes)) + _, mfEnq := queueRepo.Data[primaryKey("mf", "mf3")] + Expect(mfEnq).To(BeFalse()) + }) + + It("extracts embedded art provisionally and enqueues the track when eligible", func() { + conf.Server.EnableMediaFileCoverArt = true + mfRepo.SetData(model.MediaFiles{{ + ID: "mf4", AlbumID: "albo", HasCoverArt: true, + Path: "tests/fixtures/artist/an-album/test.mp3", LibraryID: 0, + }}) + + img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf4"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(len(readAll(img))).To(BeNumerically(">", 0)) + Expect(queueRepo.Data[primaryKey("mf", "mf4")].Priority).To(Equal(model.ArtworkPriorityBump)) + _, err = artRepo.GetItemArtwork("mf", "mf4", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + + Describe("disc", func() { + It("serves a local disc-folder image", func() { + folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}} + albumRepo.SetData(model.Albums{{ID: "aldc", Name: "Album", FolderIDs: []string{"f1"}}}) + + img, err := svc.Get(ctx, model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("aldc", 1), nil), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(readAll(img)).To(Equal(coverBytes)) + }) + + It("falls back to album art when no disc image matches", func() { + folderRepo.result = nil + albumRepo.SetData(model.Albums{{ID: "aldc2", Name: "Album"}}) + seedFoundStore("al", "aldc2", coverBytes) + + img, err := svc.Get(ctx, model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("aldc2", 1), nil), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(readAll(img)).To(Equal(coverBytes)) + }) + }) + + Describe("GetOrPlaceholder", func() { + It("accepts a raw entity id and serves its cover art", func() { + albumRepo.SetData(model.Albums{{ID: "rawal", Name: "Album"}}) + seedFoundStore("al", "rawal", coverBytes) + + img, err := svc.GetOrPlaceholder(ctx, "rawal", 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(img.Placeholder).To(BeFalse()) + Expect(readAll(img)).To(Equal(coverBytes)) + }) + + It("falls back to the album placeholder ignoring size and square", func() { + img, err := svc.GetOrPlaceholder(ctx, "", 300, true) + Expect(err).ToNot(HaveOccurred()) + Expect(img.Placeholder).To(BeTrue()) + Expect(img.Hash).To(BeEmpty()) + Expect(img.LastUpdated).To(BeZero()) + + ph, err := resources.FS().Open(consts.PlaceholderAlbumArt) + Expect(err).ToNot(HaveOccurred()) + phBytes, _ := io.ReadAll(ph) + Expect(readAll(img)).To(Equal(phBytes)) + }) + + It("falls back to the artist placeholder for an absent artist", func() { + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "arph"})).To(Succeed()) + + img, err := svc.GetOrPlaceholder(ctx, "ar-arph", 300, false) + Expect(err).ToNot(HaveOccurred()) + Expect(img.Placeholder).To(BeTrue()) + + ph, err := resources.FS().Open(consts.PlaceholderArtistArt) + Expect(err).ToNot(HaveOccurred()) + phBytes, _ := io.ReadAll(ph) + Expect(readAll(img)).To(Equal(phBytes)) + }) + }) +}) + +func fileMtime(path string) int64 { + GinkgoHelper() + info, err := os.Stat(path) + Expect(err).ToNot(HaveOccurred()) + return info.ModTime().Unix() +} diff --git a/core/artwork/wire_providers.go b/core/artwork/wire_providers.go index 868ea8f56..2c93c27e9 100644 --- a/core/artwork/wire_providers.go +++ b/core/artwork/wire_providers.go @@ -6,6 +6,7 @@ import ( var Set = wire.NewSet( NewArtwork, + NewService, GetImageCache, NewCacheWarmer, NewWorker, From 25b32f97066bd5ea418f647ff0ce6b5e6e68607a Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 21:37:04 -0400 Subject: [PATCH 62/87] feat(server): serve artwork from persisted state with content-hash caching --- cmd/wire_gen.go | 42 +++--- server/imghttp/headers.go | 61 +++++++++ server/imghttp/headers_test.go | 166 ++++++++++++++++++++++++ server/jellyfin/api.go | 4 +- server/jellyfin/e2e/e2e_suite_test.go | 11 +- server/jellyfin/images.go | 25 +++- server/jellyfin/images_test.go | 43 +++++- server/public/handle_images.go | 12 +- server/public/handle_images_test.go | 55 ++++++++ server/public/public.go | 4 +- server/subsonic/api.go | 4 +- server/subsonic/e2e/e2e_suite_test.go | 12 +- server/subsonic/media_retrieval.go | 14 +- server/subsonic/media_retrieval_test.go | 65 +++++++++- 14 files changed, 456 insertions(+), 62 deletions(-) create mode 100644 server/imghttp/headers.go create mode 100644 server/imghttp/headers_test.go diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 8f11b2efc..e7b961cf0 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -91,7 +91,14 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { sqlDB := db.Db() dataStore := persistence.New(sqlDB) fileCache := artwork.GetImageCache() + imageStore := artwork.ProvideImageStore() fFmpeg := ffmpeg.New() + service := artwork.NewService(dataStore, fileCache, imageStore, fFmpeg) + transcodingCache := stream.GetTranscodingCache() + mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) + share := core.NewShare(dataStore) + archiver := core.NewArchiver(mediaStreamer, dataStore, share) + players := core.NewPlayers(dataStore) broker := events.GetBroker() metricsMetrics := metrics.GetPrometheusInstance(dataStore) manager := plugins.GetManager(dataStore, broker, metricsMetrics) @@ -99,11 +106,6 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { matcherMatcher := matcher.New(dataStore) provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) - transcodingCache := stream.GetTranscodingCache() - mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) - share := core.NewShare(dataStore) - archiver := core.NewArchiver(mediaStreamer, dataStore, share) - players := core.NewPlayers(dataStore) cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) imageUploadService := core.NewImageUploadService() playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) @@ -113,7 +115,7 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { lyricsLyrics := lyrics.NewLyrics(dataStore, manager) transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg) sonicSonic := sonic.New(dataStore, manager, matcherMatcher) - router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, sonicSonic) + router := subsonic.New(dataStore, service, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, sonicSonic) return router } @@ -121,24 +123,25 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router { sqlDB := db.Db() dataStore := persistence.New(sqlDB) fileCache := artwork.GetImageCache() + imageStore := artwork.ProvideImageStore() fFmpeg := ffmpeg.New() - broker := events.GetBroker() - metricsMetrics := metrics.GetPrometheusInstance(dataStore) - manager := plugins.GetManager(dataStore, broker, metricsMetrics) - agentsAgents := agents.GetAgents(dataStore, manager) - matcherMatcher := matcher.New(dataStore) - provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) - artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) + service := artwork.NewService(dataStore, fileCache, imageStore, fFmpeg) transcodingCache := stream.GetTranscodingCache() mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg) players := core.NewPlayers(dataStore) + broker := events.GetBroker() + metricsMetrics := metrics.GetPrometheusInstance(dataStore) + manager := plugins.GetManager(dataStore, broker, metricsMetrics) playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) imageUploadService := core.NewImageUploadService() playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) + agentsAgents := agents.GetAgents(dataStore, manager) + matcherMatcher := matcher.New(dataStore) + provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) sonicSonic := sonic.New(dataStore, manager, matcherMatcher) lyricsLyrics := lyrics.NewLyrics(dataStore, manager) - router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics, broker) + router := jellyfin.New(dataStore, service, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics, broker) return router } @@ -146,19 +149,14 @@ func CreatePublicRouter() *public.Router { sqlDB := db.Db() dataStore := persistence.New(sqlDB) fileCache := artwork.GetImageCache() + imageStore := artwork.ProvideImageStore() fFmpeg := ffmpeg.New() - broker := events.GetBroker() - metricsMetrics := metrics.GetPrometheusInstance(dataStore) - manager := plugins.GetManager(dataStore, broker, metricsMetrics) - agentsAgents := agents.GetAgents(dataStore, manager) - matcherMatcher := matcher.New(dataStore) - provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) - artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) + service := artwork.NewService(dataStore, fileCache, imageStore, fFmpeg) transcodingCache := stream.GetTranscodingCache() mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) share := core.NewShare(dataStore) archiver := core.NewArchiver(mediaStreamer, dataStore, share) - router := public.New(dataStore, artworkArtwork, mediaStreamer, share, archiver) + router := public.New(dataStore, service, mediaStreamer, share, archiver) return router } diff --git a/server/imghttp/headers.go b/server/imghttp/headers.go new file mode 100644 index 000000000..a5f3cf43e --- /dev/null +++ b/server/imghttp/headers.go @@ -0,0 +1,61 @@ +// Package imghttp holds the shared HTTP caching contract for artwork responses, so the +// subsonic, public, and jellyfin image handlers apply identical headers without importing +// each other. +package imghttp + +import ( + "net/http" + "strings" + + "github.com/navidrome/navidrome/core/artwork" +) + +// WriteImageHeaders applies the artwork caching contract and reports whether a 304 was written +// (in which case the caller must not write a body). requestedHash is the hash the client asserted +// (id suffix / JWT payload / jellyfin tag param), or "" when the request carried no hash. +func WriteImageHeaders(w http.ResponseWriter, r *http.Request, img *artwork.Image, requestedHash string) (wrote304 bool) { + h := w.Header() + // Placeholders are transient stand-ins for not-yet-resolved art: never cached, no validators. + if img.Placeholder { + h.Set("Cache-Control", "no-store") + return false + } + + h.Set("ETag", `"`+img.Hash+`"`) + if !img.LastUpdated.IsZero() { + h.Set("Last-Modified", img.LastUpdated.UTC().Format(http.TimeFormat)) + } + // Immutable only when the client asked for the exact current hash; bare/legacy/mismatched + // requests get cheap ETag revalidation instead, which fixes stale art after re-resolution. + if requestedHash != "" && requestedHash == img.Hash { + h.Set("Cache-Control", "public, max-age=31536000, immutable") + } else { + h.Set("Cache-Control", "public, no-cache") + } + + if ifNoneMatch(r.Header.Get("If-None-Match"), img.Hash) { + w.WriteHeader(http.StatusNotModified) + return true + } + return false +} + +// ifNoneMatch reports whether the If-None-Match header asserts the given hash, using weak +// comparison (RFC 9110): "*" matches any current representation and W/ prefixes are ignored. +func ifNoneMatch(header, hash string) bool { + header = strings.TrimSpace(header) + if header == "" { + return false + } + if header == "*" { + return true + } + for _, tag := range strings.Split(header, ",") { + tag = strings.TrimSpace(tag) + tag = strings.TrimPrefix(tag, "W/") + if strings.Trim(tag, `"`) == hash { + return true + } + } + return false +} diff --git a/server/imghttp/headers_test.go b/server/imghttp/headers_test.go new file mode 100644 index 000000000..6bd824fbe --- /dev/null +++ b/server/imghttp/headers_test.go @@ -0,0 +1,166 @@ +package imghttp_test + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/server/imghttp" +) + +const testHash = "0123456789abcdef" + +var lastMod = time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC) + +func found() *artwork.Image { + return &artwork.Image{ + ReadCloser: io.NopCloser(strings.NewReader("IMG")), + Hash: testHash, + LastUpdated: lastMod, + } +} + +func placeholder() *artwork.Image { + return &artwork.Image{ReadCloser: io.NopCloser(strings.NewReader("PH")), Placeholder: true} +} + +func TestWriteImageHeaders(t *testing.T) { + tests := []struct { + name string + img *artwork.Image + requestedHash string + ifNoneMatch string + want304 bool + wantCache string + wantETag string + wantLastMod bool + }{ + { + name: "placeholder is never cached and carries no validators", + img: placeholder(), + wantCache: "no-store", + }, + { + name: "found with matching requested hash is immutable", + img: found(), + requestedHash: testHash, + wantCache: "public, max-age=31536000, immutable", + wantETag: `"` + testHash + `"`, + wantLastMod: true, + }, + { + name: "found with bare id revalidates via no-cache", + img: found(), + wantCache: "public, no-cache", + wantETag: `"` + testHash + `"`, + wantLastMod: true, + }, + { + name: "found with mismatched requested hash revalidates", + img: found(), + requestedHash: "ffffffffffffffff", + wantCache: "public, no-cache", + wantETag: `"` + testHash + `"`, + wantLastMod: true, + }, + { + name: "If-None-Match matching the hash yields 304", + img: found(), + requestedHash: testHash, + ifNoneMatch: `"` + testHash + `"`, + want304: true, + wantCache: "public, max-age=31536000, immutable", + wantETag: `"` + testHash + `"`, + wantLastMod: true, + }, + { + name: "weak If-None-Match matches (weak comparison)", + img: found(), + ifNoneMatch: `W/"` + testHash + `"`, + want304: true, + wantCache: "public, no-cache", + wantETag: `"` + testHash + `"`, + wantLastMod: true, + }, + { + name: "If-None-Match with multiple values matches one", + img: found(), + ifNoneMatch: `"deadbeefdeadbeef", W/"` + testHash + `", "cafecafecafecafe"`, + want304: true, + wantCache: "public, no-cache", + wantETag: `"` + testHash + `"`, + wantLastMod: true, + }, + { + name: "If-None-Match star matches any current representation", + img: found(), + ifNoneMatch: "*", + want304: true, + wantCache: "public, no-cache", + wantETag: `"` + testHash + `"`, + wantLastMod: true, + }, + { + name: "non-matching If-None-Match serves body", + img: found(), + ifNoneMatch: `"deadbeefdeadbeef"`, + want304: false, + wantCache: "public, no-cache", + wantETag: `"` + testHash + `"`, + wantLastMod: true, + }, + { + name: "placeholder ignores If-None-Match and never 304s", + img: placeholder(), + ifNoneMatch: "*", + want304: false, + wantCache: "no-store", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/img", nil) + if tc.ifNoneMatch != "" { + r.Header.Set("If-None-Match", tc.ifNoneMatch) + } + + got := imghttp.WriteImageHeaders(w, r, tc.img, tc.requestedHash) + if got != tc.want304 { + t.Fatalf("wrote304 = %v, want %v", got, tc.want304) + } + + h := w.Header() + if got := h.Get("Cache-Control"); got != tc.wantCache { + t.Errorf("Cache-Control = %q, want %q", got, tc.wantCache) + } + if got := h.Get("ETag"); got != tc.wantETag { + t.Errorf("ETag = %q, want %q", got, tc.wantETag) + } + if tc.img.Placeholder { + if h.Get("ETag") != "" { + t.Errorf("placeholder must not set ETag, got %q", h.Get("ETag")) + } + if h.Get("Last-Modified") != "" { + t.Errorf("placeholder must not set Last-Modified, got %q", h.Get("Last-Modified")) + } + } + if hasLM := h.Get("Last-Modified") != ""; hasLM != tc.wantLastMod { + t.Errorf("has Last-Modified = %v, want %v", hasLM, tc.wantLastMod) + } + if tc.want304 { + if w.Code != http.StatusNotModified { + t.Errorf("status = %d, want 304", w.Code) + } + if w.Body.Len() != 0 { + t.Errorf("304 must have empty body, got %q", w.Body.String()) + } + } + }) + } +} diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index 1f46c08b4..f7a38feb4 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -30,7 +30,7 @@ import ( type Router struct { http.Handler ds model.DataStore - artwork artwork.Artwork + artwork artwork.Service streamer stream.MediaStreamer transcodeDecider stream.TranscodeDecider players core.Players @@ -46,7 +46,7 @@ type Router struct { serverIDVal string } -func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer, +func New(ds model.DataStore, artwork artwork.Service, streamer stream.MediaStreamer, transcodeDecider stream.TranscodeDecider, players core.Players, scrobbler scrobbler.PlayTracker, playlists playlists.Playlists, provider external.Provider, sonicSvc sonic.Engine, lyricsSvc lyrics.Lyrics, broker events.Broker) *Router { diff --git a/server/jellyfin/e2e/e2e_suite_test.go b/server/jellyfin/e2e/e2e_suite_test.go index 31d98d1d9..1986a95ec 100644 --- a/server/jellyfin/e2e/e2e_suite_test.go +++ b/server/jellyfin/e2e/e2e_suite_test.go @@ -33,7 +33,6 @@ import ( "strings" "testing" "testing/fstest" - "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" @@ -406,18 +405,18 @@ type spyArtwork struct { data []byte } -func (s *spyArtwork) Get(context.Context, model.ArtworkID, int, bool) (io.ReadCloser, time.Time, error) { - return nil, time.Time{}, model.ErrNotFound +func (s *spyArtwork) Get(context.Context, model.ArtworkID, int, bool) (*artwork.Image, error) { + return nil, model.ErrNotFound } -func (s *spyArtwork) GetOrPlaceholder(c context.Context, id string, _ int, _ bool) (io.ReadCloser, time.Time, error) { +func (s *spyArtwork) GetOrPlaceholder(c context.Context, id string, _ int, _ bool) (*artwork.Image, error) { s.lastID = id s.lastCtx = c d := s.data if d == nil { d = []byte("IMG") } - return io.NopCloser(bytes.NewReader(d)), time.Time{}, nil + return &artwork.Image{ReadCloser: io.NopCloser(bytes.NewReader(d))}, nil } -var _ artwork.Artwork = &spyArtwork{} +var _ artwork.Service = &spyArtwork{} diff --git a/server/jellyfin/images.go b/server/jellyfin/images.go index 0ec34f491..35042e9c4 100644 --- a/server/jellyfin/images.go +++ b/server/jellyfin/images.go @@ -20,6 +20,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/imghttp" "github.com/navidrome/navidrome/server/jellyfin/dto" _ "golang.org/x/image/webp" ) @@ -32,7 +33,7 @@ func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) { size, _ := strconv.Atoi(r.URL.Query().Get("maxwidth")) artID := api.resolveArtworkID(ctx, itemId) - reader, _, err := api.artwork.GetOrPlaceholder(ctx, artID, size, false) + img, err := api.artwork.GetOrPlaceholder(ctx, artID, size, false) switch { case errors.Is(err, context.Canceled): return @@ -41,9 +42,27 @@ func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) { http.Error(w, "Not Found", http.StatusNotFound) return } - defer reader.Close() + defer img.Close() + if imghttp.WriteImageHeaders(w, r, img, hashFromTag(r)) { + return + } // Leave Content-Type unset so net/http sniffs it (covers may be PNG/WebP/JPEG). - _, _ = io.Copy(w, reader) + _, _ = io.Copy(w, img) +} + +// hashFromTag returns the ?tag query param when it is exactly a 16-char lowercase-hex content +// hash (what Finamp/Jellyfin clients append), so a matching request can be served immutable. +func hashFromTag(r *http.Request) string { + tag := r.URL.Query().Get("tag") + if len(tag) != 16 { + return "" + } + for _, c := range tag { + if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') { + return "" + } + } + return tag } // resolveArtworkID maps a Jellyfin item id to a Navidrome ArtworkID, probing diff --git a/server/jellyfin/images_test.go b/server/jellyfin/images_test.go index e435b99fd..65285e3c5 100644 --- a/server/jellyfin/images_test.go +++ b/server/jellyfin/images_test.go @@ -29,20 +29,25 @@ import ( ) type fakeArtwork struct { - artwork.Artwork + artwork.Service recvId string recvCtx context.Context data []byte + hash string } -func (f *fakeArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) { +func (f *fakeArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (*artwork.Image, error) { f.recvId = id f.recvCtx = ctx data := f.data if data == nil { data = []byte("IMG") } - return io.NopCloser(bytes.NewReader(data)), time.Now(), nil + return &artwork.Image{ + ReadCloser: io.NopCloser(bytes.NewReader(data)), + Hash: f.hash, + LastUpdated: time.Now(), + }, nil } func newImageRequest(itemId string) (*httptest.ResponseRecorder, *http.Request) { @@ -115,6 +120,38 @@ var _ = Describe("Images", func() { Expect(ok).To(BeTrue()) Expect(u.IsAdmin).To(BeTrue()) }) + + It("serves immutable when the tag param asserts the current hash", func() { + const hash = "0123456789abcdef" + ds := &tests.MockDataStore{} + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + fa := &fakeArtwork{hash: hash} + api := &Router{ds: ds, artwork: fa} + + w, r := newImageRequest(dto.EncodeID("a1")) + q := r.URL.Query() + q.Set("tag", hash) + r.URL.RawQuery = q.Encode() + api.getItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Cache-Control")).To(Equal("public, max-age=31536000, immutable")) + Expect(w.Header().Get("ETag")).To(Equal(`"` + hash + `"`)) + }) + + It("revalidates via no-cache when no tag is provided", func() { + const hash = "0123456789abcdef" + ds := &tests.MockDataStore{} + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + fa := &fakeArtwork{hash: hash} + api := &Router{ds: ds, artwork: fa} + + w, r := newImageRequest(dto.EncodeID("a1")) + api.getItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Cache-Control")).To(Equal("public, no-cache")) + }) }) // Real image fixtures: postItemImage validates uploads by decoding them. diff --git a/server/public/handle_images.go b/server/public/handle_images.go index 50f9238e5..9bcaa5933 100644 --- a/server/public/handle_images.go +++ b/server/public/handle_images.go @@ -11,6 +11,7 @@ import ( "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/imghttp" "github.com/navidrome/navidrome/utils/req" ) @@ -40,7 +41,7 @@ func (pub *Router) handleImages(w http.ResponseWriter, r *http.Request) { size := p.IntOr("size", 0) square := p.BoolOr("square", false) - imgReader, lastUpdate, err := pub.artwork.Get(ctx, artId, size, square) + img, err := pub.artwork.Get(ctx, artId, size, square) switch { case errors.Is(err, context.Canceled): return @@ -58,10 +59,11 @@ func (pub *Router) handleImages(w http.ResponseWriter, r *http.Request) { return } - defer imgReader.Close() - w.Header().Set("Cache-Control", "public, max-age=315360000") - w.Header().Set("Last-Modified", lastUpdate.Format(http.TimeFormat)) - cnt, err := io.Copy(w, imgReader) + defer img.Close() + if imghttp.WriteImageHeaders(w, r, img, artId.Hash) { + return + } + cnt, err := io.Copy(w, img) if err != nil { log.Warn(ctx, "Error sending image", "count", cnt, err) } diff --git a/server/public/handle_images_test.go b/server/public/handle_images_test.go index 6895241f6..edcf9d8c0 100644 --- a/server/public/handle_images_test.go +++ b/server/public/handle_images_test.go @@ -1,8 +1,17 @@ package public import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "net/url" + "github.com/go-chi/jwtauth/v5" + "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -23,3 +32,49 @@ var _ = Describe("decodeArtworkID", func() { Expect(err).To(HaveOccurred()) }) }) + +type fakeArtwork struct { + artwork.Service + img *artwork.Image + err error +} + +func (f *fakeArtwork) Get(context.Context, model.ArtworkID, int, bool) (*artwork.Image, error) { + return f.img, f.err +} + +var _ = Describe("handleImages", func() { + var w *httptest.ResponseRecorder + + newImageRequest := func(claimID string) *http.Request { + auth.TokenAuth = jwtauth.New("HS256", []byte("super secret"), nil) + token, _ := auth.CreatePublicToken(auth.Claims{ID: claimID}) + return httptest.NewRequest("GET", "/img?:id="+url.QueryEscape(token), nil) + } + + BeforeEach(func() { + w = httptest.NewRecorder() + }) + + It("returns 404 when the artwork is unavailable", func() { + pub := &Router{artwork: &fakeArtwork{err: artwork.ErrUnavailable}} + pub.handleImages(w, newImageRequest("al-1")) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 404 when the artwork is not found", func() { + pub := &Router{artwork: &fakeArtwork{err: model.ErrNotFound}} + pub.handleImages(w, newImageRequest("al-1")) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("serves the image immutable when the token asserts the current hash", func() { + const hash = "0123456789abcdef" + img := &artwork.Image{ReadCloser: io.NopCloser(bytes.NewReader([]byte("IMG"))), Hash: hash} + pub := &Router{artwork: &fakeArtwork{img: img}} + pub.handleImages(w, newImageRequest("al-1_"+hash)) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Cache-Control")).To(Equal("public, max-age=31536000, immutable")) + Expect(w.Header().Get("ETag")).To(Equal(`"` + hash + `"`)) + }) +}) diff --git a/server/public/public.go b/server/public/public.go index 18867e1c4..35155ec59 100644 --- a/server/public/public.go +++ b/server/public/public.go @@ -18,7 +18,7 @@ import ( type Router struct { http.Handler - artwork artwork.Artwork + artwork artwork.Service streamer stream.MediaStreamer archiver core.Archiver share core.Share @@ -26,7 +26,7 @@ type Router struct { ds model.DataStore } -func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer, share core.Share, archiver core.Archiver) *Router { +func New(ds model.DataStore, artwork artwork.Service, streamer stream.MediaStreamer, share core.Share, archiver core.Archiver) *Router { p := &Router{ds: ds, artwork: artwork, streamer: streamer, share: share, archiver: archiver} shareRoot := path.Join(conf.Server.BasePath, consts.URLPathPublic) p.assetsHandler = http.StripPrefix(shareRoot, http.FileServer(http.FS(ui.BuildAssets()))) diff --git a/server/subsonic/api.go b/server/subsonic/api.go index 82e404228..d57da7452 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -40,7 +40,7 @@ type handlerRaw = func(http.ResponseWriter, *http.Request) (*responses.Subsonic, type Router struct { http.Handler ds model.DataStore - artwork artwork.Artwork + artwork artwork.Service streamer stream.MediaStreamer archiver core.Archiver players core.Players @@ -57,7 +57,7 @@ type Router struct { sonic *sonicsvc.Sonic } -func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer, archiver core.Archiver, +func New(ds model.DataStore, artwork artwork.Service, streamer stream.MediaStreamer, archiver core.Archiver, players core.Players, provider external.Provider, scanner model.Scanner, broker events.Broker, playlists playlistsvc.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer, metrics metrics.Metrics, lyrics lyricssvc.Lyrics, transcodeDecision stream.TranscodeDecider, diff --git a/server/subsonic/e2e/e2e_suite_test.go b/server/subsonic/e2e/e2e_suite_test.go index 98ad6d1ee..bd4651897 100644 --- a/server/subsonic/e2e/e2e_suite_test.go +++ b/server/subsonic/e2e/e2e_suite_test.go @@ -308,15 +308,15 @@ func parseJSONResponse(w *httptest.ResponseRecorder) *responses.Subsonic { // --- Noop stub implementations for Router dependencies --- -// noopArtwork implements artwork.Artwork +// noopArtwork implements artwork.Service type noopArtwork struct{} -func (n noopArtwork) Get(context.Context, model.ArtworkID, int, bool) (io.ReadCloser, time.Time, error) { - return nil, time.Time{}, model.ErrNotFound +func (n noopArtwork) Get(context.Context, model.ArtworkID, int, bool) (*artwork.Image, error) { + return nil, model.ErrNotFound } -func (n noopArtwork) GetOrPlaceholder(_ context.Context, _ string, _ int, _ bool) (io.ReadCloser, time.Time, error) { - return io.NopCloser(io.LimitReader(nil, 0)), time.Time{}, nil +func (n noopArtwork) GetOrPlaceholder(_ context.Context, _ string, _ int, _ bool) (*artwork.Image, error) { + return &artwork.Image{ReadCloser: io.NopCloser(io.LimitReader(nil, 0))}, nil } // noopArchiver implements core.Archiver @@ -371,7 +371,7 @@ func (n noopProvider) AlbumImage(context.Context, string) (*url.URL, error) { // Compile-time interface checks var ( - _ artwork.Artwork = noopArtwork{} + _ artwork.Service = noopArtwork{} _ core.Archiver = noopArchiver{} _ external.Provider = noopProvider{} ) diff --git a/server/subsonic/media_retrieval.go b/server/subsonic/media_retrieval.go index 089a1fdda..30124c5f8 100644 --- a/server/subsonic/media_retrieval.go +++ b/server/subsonic/media_retrieval.go @@ -13,6 +13,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/resources" + "github.com/navidrome/navidrome/server/imghttp" "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/utils/gravatar" "github.com/navidrome/navidrome/utils/req" @@ -66,7 +67,7 @@ func (api *Router) GetCoverArt(w http.ResponseWriter, r *http.Request) (*respons size := p.IntOr("size", 0) square := p.BoolOr("square", false) - imgReader, lastUpdate, err := api.artwork.GetOrPlaceholder(ctx, id, size, square) + img, err := api.artwork.GetOrPlaceholder(ctx, id, size, square) switch { case errors.Is(err, context.Canceled): return nil, nil @@ -77,12 +78,13 @@ func (api *Router) GetCoverArt(w http.ResponseWriter, r *http.Request) (*respons log.Error(r, "Error retrieving coverArt", "id", id, err) return nil, err } + defer img.Close() - defer imgReader.Close() - w.Header().Set("cache-control", "public, max-age=315360000") - w.Header().Set("last-modified", lastUpdate.Format(http.TimeFormat)) - - cnt, err := io.Copy(w, imgReader) + artID, _ := model.ParseArtworkID(id) + if imghttp.WriteImageHeaders(w, r, img, artID.Hash) { + return nil, nil + } + cnt, err := io.Copy(w, img) if err != nil { log.Warn(ctx, "Error sending image", "count", cnt, err) } diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go index 9331dfbe4..db15d5bfd 100644 --- a/server/subsonic/media_retrieval_test.go +++ b/server/subsonic/media_retrieval_test.go @@ -108,6 +108,53 @@ var _ = Describe("MediaRetrievalController", func() { Expect(w.Body.String()).To(BeEmpty()) }) }) + + Describe("caching headers", func() { + const hash = "0123456789abcdef" + + It("sets an ETag and no-cache for a bare id", func() { + artwork.hash = hash + r := newGetRequest("id=al-34") + _, err := router.GetCoverArt(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(w.Header().Get("ETag")).To(Equal(`"` + hash + `"`)) + Expect(w.Header().Get("Cache-Control")).To(Equal("public, no-cache")) + Expect(w.Body.String()).To(Equal(artwork.data)) + }) + + It("marks the response immutable when the id asserts the current hash", func() { + artwork.hash = hash + r := newGetRequest("id=al-34_" + hash) + _, err := router.GetCoverArt(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(w.Header().Get("Cache-Control")).To(Equal("public, max-age=31536000, immutable")) + }) + + It("returns 304 with no body when If-None-Match matches", func() { + artwork.hash = hash + r := newGetRequest("id=al-34") + r.Header.Set("If-None-Match", `"`+hash+`"`) + _, err := router.GetCoverArt(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(w.Code).To(Equal(304)) + Expect(w.Body.Len()).To(BeZero()) + }) + + It("never caches a placeholder", func() { + artwork.placeholder = true + r := newGetRequest("id=al-missing") + _, err := router.GetCoverArt(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(w.Code).To(Equal(200)) + Expect(w.Header().Get("Cache-Control")).To(Equal("no-store")) + Expect(w.Header().Get("ETag")).To(BeEmpty()) + Expect(w.Body.String()).To(Equal(artwork.data)) + }) + }) }) Describe("GetLyrics", func() { @@ -186,8 +233,11 @@ var _ = Describe("MediaRetrievalController", func() { }) type fakeArtwork struct { - artwork.Artwork + artwork.Service data string + hash string + lastUpdated time.Time + placeholder bool err error ctxCancelFunc func() recvId string @@ -195,18 +245,23 @@ type fakeArtwork struct { recvSquare bool } -func (c *fakeArtwork) GetOrPlaceholder(_ context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) { +func (c *fakeArtwork) GetOrPlaceholder(_ context.Context, id string, size int, square bool) (*artwork.Image, error) { if c.err != nil { - return nil, time.Time{}, c.err + return nil, c.err } c.recvId = id c.recvSize = size c.recvSquare = square if c.ctxCancelFunc != nil { c.ctxCancelFunc() - return nil, time.Time{}, context.Canceled + return nil, context.Canceled } - return io.NopCloser(bytes.NewReader([]byte(c.data))), time.Time{}, nil + return &artwork.Image{ + ReadCloser: io.NopCloser(bytes.NewReader([]byte(c.data))), + Hash: c.hash, + LastUpdated: c.lastUpdated, + Placeholder: c.placeholder, + }, nil } type mockedMediaFile struct { From 8fea7efa5075c9fac5179ca603471ab59397f5ea Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 21:53:24 -0400 Subject: [PATCH 63/87] feat(subsonic): content-hash coverArt ids, omit artwork on known-absent --- server/subsonic/browsing.go | 18 ++++---- server/subsonic/browsing_test.go | 54 ++++++++++++++++++++++++ server/subsonic/helpers.go | 41 ++++++++++++------- server/subsonic/helpers_test.go | 68 +++++++++++++++++++++++++++++++ server/subsonic/playlists.go | 2 +- server/subsonic/playlists_test.go | 13 ++++++ server/subsonic/radio.go | 4 +- server/subsonic/radio_test.go | 15 +++++++ server/subsonic/searching.go | 12 +++--- server/subsonic/searching_test.go | 30 ++++++++++++++ 10 files changed, 228 insertions(+), 29 deletions(-) diff --git a/server/subsonic/browsing.go b/server/subsonic/browsing.go index f6a7047c4..581ffb157 100644 --- a/server/subsonic/browsing.go +++ b/server/subsonic/browsing.go @@ -230,9 +230,11 @@ func (api *Router) GetAlbumInfo(r *http.Request) (*responses.Subsonic, error) { response := newResponse() response.AlbumInfo = &responses.AlbumInfo{} response.AlbumInfo.Notes = album.Description - response.AlbumInfo.SmallImageUrl = publicurl.ImageURL(r, album.CoverArtID(), 300) - response.AlbumInfo.MediumImageUrl = publicurl.ImageURL(r, album.CoverArtID(), 600) - response.AlbumInfo.LargeImageUrl = publicurl.ImageURL(r, album.CoverArtID(), 1200) + if !album.ImageAbsent { + response.AlbumInfo.SmallImageUrl = publicurl.ImageURL(r, album.CoverArtID(), 300) + response.AlbumInfo.MediumImageUrl = publicurl.ImageURL(r, album.CoverArtID(), 600) + response.AlbumInfo.LargeImageUrl = publicurl.ImageURL(r, album.CoverArtID(), 1200) + } response.AlbumInfo.LastFmUrl = album.ExternalUrl response.AlbumInfo.MusicBrainzID = album.MbzAlbumID @@ -295,9 +297,11 @@ func (api *Router) getArtistInfo(r *http.Request) (*responses.ArtistInfoBase, *m base := responses.ArtistInfoBase{} base.Biography = artist.Biography - base.SmallImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 300) - base.MediumImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 600) - base.LargeImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 1200) + if !artist.ImageAbsent { + base.SmallImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 300) + base.MediumImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 600) + base.LargeImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 1200) + } base.LastFmUrl = artist.ExternalUrl base.MusicBrainzID = artist.MbzArtistID @@ -453,7 +457,7 @@ func (api *Router) buildAlbumDirectory(ctx context.Context, album *model.Album) dir.AverageRating = album.AverageRating } dir.SongCount = int32(album.SongCount) - dir.CoverArt = album.CoverArtID().String() + dir.CoverArt = coverArtOrEmpty(album.CoverArtID(), album.ImageAbsent) if album.Starred { dir.Starred = album.StarredAt } diff --git a/server/subsonic/browsing_test.go b/server/subsonic/browsing_test.go index b8f510aed..d34da2f37 100644 --- a/server/subsonic/browsing_test.go +++ b/server/subsonic/browsing_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" @@ -157,4 +158,57 @@ var _ = Describe("Browsing", func() { Expect(response.Artist).ToNot(BeNil()) }) }) + + Describe("GetAlbumInfo", func() { + It("emits image URLs when the album artwork is unresolved", func() { + api.provider = &fakeInfoProvider{album: &model.Album{ID: "al-1"}} + r := httptest.NewRequest("GET", "/rest/getAlbumInfo?id=al-1", nil) + resp, err := api.GetAlbumInfo(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.AlbumInfo.SmallImageUrl).ToNot(BeEmpty()) + Expect(resp.AlbumInfo.LargeImageUrl).ToNot(BeEmpty()) + }) + It("omits image URLs when the album artwork is known absent", func() { + api.provider = &fakeInfoProvider{album: &model.Album{ID: "al-1", ItemImage: model.ItemImage{ImageAbsent: true}}} + r := httptest.NewRequest("GET", "/rest/getAlbumInfo?id=al-1", nil) + resp, err := api.GetAlbumInfo(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.AlbumInfo.SmallImageUrl).To(BeEmpty()) + Expect(resp.AlbumInfo.MediumImageUrl).To(BeEmpty()) + Expect(resp.AlbumInfo.LargeImageUrl).To(BeEmpty()) + }) + }) + + Describe("GetArtistInfo", func() { + It("emits image URLs when the artist artwork is unresolved", func() { + api.provider = &fakeInfoProvider{artist: &model.Artist{ID: "ar-1"}} + r := httptest.NewRequest("GET", "/rest/getArtistInfo?id=ar-1", nil) + resp, err := api.GetArtistInfo(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.ArtistInfo.SmallImageUrl).ToNot(BeEmpty()) + }) + It("omits image URLs when the artist artwork is known absent", func() { + api.provider = &fakeInfoProvider{artist: &model.Artist{ID: "ar-1", ItemImage: model.ItemImage{ImageAbsent: true}}} + r := httptest.NewRequest("GET", "/rest/getArtistInfo?id=ar-1", nil) + resp, err := api.GetArtistInfo(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.ArtistInfo.SmallImageUrl).To(BeEmpty()) + Expect(resp.ArtistInfo.MediumImageUrl).To(BeEmpty()) + Expect(resp.ArtistInfo.LargeImageUrl).To(BeEmpty()) + }) + }) }) + +type fakeInfoProvider struct { + external.Provider + album *model.Album + artist *model.Artist +} + +func (f *fakeInfoProvider) UpdateAlbumInfo(context.Context, string) (*model.Album, error) { + return f.album, nil +} + +func (f *fakeInfoProvider) UpdateArtistInfo(context.Context, string, int, bool) (*model.Artist, error) { + return f.artist, nil +} diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index b986200ae..e367cdee5 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -95,13 +95,24 @@ func getArtistAlbumCount(a *model.Artist) int32 { } } +// coverArtOrEmpty stamps the (hash-suffixed) coverArt id, or omits it (empty +// string + the field's omitempty tag) when artwork is known absent. +func coverArtOrEmpty(id model.ArtworkID, absent bool) string { + if absent { + return "" + } + return id.String() +} + func toArtist(r *http.Request, a model.Artist) responses.Artist { artist := responses.Artist{ - Id: a.ID, - Name: a.Name, - UserRating: int32(a.Rating), - CoverArt: a.CoverArtID().String(), - ArtistImageUrl: publicurl.ImageURL(r, a.CoverArtID(), 600), + Id: a.ID, + Name: a.Name, + UserRating: int32(a.Rating), + CoverArt: coverArtOrEmpty(a.CoverArtID(), a.ImageAbsent), + } + if !a.ImageAbsent { + artist.ArtistImageUrl = publicurl.ImageURL(r, a.CoverArtID(), 600) } if conf.Server.Subsonic.EnableAverageRating { artist.AverageRating = a.AverageRating @@ -114,12 +125,14 @@ func toArtist(r *http.Request, a model.Artist) responses.Artist { func toArtistID3(r *http.Request, a model.Artist) responses.ArtistID3 { artist := responses.ArtistID3{ - Id: a.ID, - Name: a.Name, - AlbumCount: getArtistAlbumCount(&a), - CoverArt: a.CoverArtID().String(), - ArtistImageUrl: publicurl.ImageURL(r, a.CoverArtID(), 600), - UserRating: int32(a.Rating), + Id: a.ID, + Name: a.Name, + AlbumCount: getArtistAlbumCount(&a), + CoverArt: coverArtOrEmpty(a.CoverArtID(), a.ImageAbsent), + UserRating: int32(a.Rating), + } + if !a.ImageAbsent { + artist.ArtistImageUrl = publicurl.ImageURL(r, a.CoverArtID(), 600) } if conf.Server.Subsonic.EnableAverageRating { artist.AverageRating = a.AverageRating @@ -208,7 +221,7 @@ func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child child.Size = mf.Size child.Suffix = mf.Suffix child.BitRate = int32(mf.BitRate) - child.CoverArt = mf.CoverArtID().String() + child.CoverArt = coverArtOrEmpty(mf.CoverArtID(), mf.ImageAbsent) child.ContentType = mf.ContentType() if ok && player.ReportRealPath { @@ -369,7 +382,7 @@ func childFromAlbum(ctx context.Context, al model.Album) responses.Child { child.Artist = al.AlbumArtist child.Year = int32(cmp.Or(al.MaxOriginalYear, al.MaxYear)) child.Genre = al.Genre - child.CoverArt = al.CoverArtID().String() + child.CoverArt = coverArtOrEmpty(al.CoverArtID(), al.ImageAbsent) child.Created = new(albumCreatedAt(al)) child.Parent = al.AlbumArtistID child.ArtistId = al.AlbumArtistID @@ -460,7 +473,7 @@ func buildAlbumID3(ctx context.Context, album model.Album) responses.AlbumID3 { dir.Name = album.FullName() dir.Artist = album.AlbumArtist dir.ArtistId = album.AlbumArtistID - dir.CoverArt = album.CoverArtID().String() + dir.CoverArt = coverArtOrEmpty(album.CoverArtID(), album.ImageAbsent) dir.SongCount = int32(album.SongCount) dir.Duration = int32(album.Duration) dir.PlayCount = album.PlayCount diff --git a/server/subsonic/helpers_test.go b/server/subsonic/helpers_test.go index 3741462a2..eeb1ae544 100644 --- a/server/subsonic/helpers_test.go +++ b/server/subsonic/helpers_test.go @@ -320,6 +320,74 @@ var _ = Describe("helpers", func() { }) }) + Describe("artwork coverArt emission", func() { + const hash = "0123456789abcdef" + ctx := context.Background() + + DescribeTable("coverArtOrEmpty", + func(id model.ArtworkID, absent bool, expected string) { + Expect(coverArtOrEmpty(id, absent)).To(Equal(expected)) + }, + Entry("emits the bare id when unresolved", model.ArtworkID{Kind: model.KindAlbumArtwork, ID: "1"}, false, "al-1"), + Entry("emits the hash-suffixed id when resolved", model.ArtworkID{Kind: model.KindAlbumArtwork, ID: "1", Hash: hash}, false, "al-1_"+hash), + Entry("omits (empty) when known absent", model.ArtworkID{Kind: model.KindAlbumArtwork, ID: "1", Hash: hash}, true, ""), + ) + + Describe("childFromAlbum", func() { + It("suffixes coverArt with the content hash when resolved", func() { + al := model.Album{ID: "al-1", ItemImage: model.ItemImage{ImageHash: hash}} + Expect(childFromAlbum(ctx, al).CoverArt).To(Equal("al-al-1_" + hash)) + }) + It("omits coverArt when known absent", func() { + al := model.Album{ID: "al-1", ItemImage: model.ItemImage{ImageAbsent: true}} + Expect(childFromAlbum(ctx, al).CoverArt).To(BeEmpty()) + }) + It("emits the bare id when unresolved", func() { + al := model.Album{ID: "al-1"} + Expect(childFromAlbum(ctx, al).CoverArt).To(Equal("al-al-1")) + }) + }) + + Describe("childFromMediaFile", func() { + It("suffixes coverArt with the content hash when resolved", func() { + mf := model.MediaFile{ID: "mf-1", AlbumID: "al-1", ItemImage: model.ItemImage{ImageHash: hash}} + Expect(childFromMediaFile(ctx, mf).CoverArt).To(Equal("al-al-1_" + hash)) + }) + It("omits coverArt when known absent", func() { + mf := model.MediaFile{ID: "mf-1", AlbumID: "al-1", ItemImage: model.ItemImage{ImageAbsent: true}} + Expect(childFromMediaFile(ctx, mf).CoverArt).To(BeEmpty()) + }) + It("emits the bare id when unresolved", func() { + mf := model.MediaFile{ID: "mf-1", AlbumID: "al-1"} + Expect(childFromMediaFile(ctx, mf).CoverArt).To(Equal("al-al-1")) + }) + }) + + Describe("toArtist / toArtistID3", func() { + r := httptest.NewRequest("GET", "/test", nil) + It("emits both coverArt and imageUrl when resolved", func() { + a := model.Artist{ID: "ar-1", ItemImage: model.ItemImage{ImageHash: hash}} + artist := toArtist(r, a) + Expect(artist.CoverArt).To(Equal("ar-ar-1_" + hash)) + Expect(artist.ArtistImageUrl).ToNot(BeEmpty()) + }) + It("omits coverArt and imageUrl when known absent", func() { + a := model.Artist{ID: "ar-1", ItemImage: model.ItemImage{ImageAbsent: true}} + artist := toArtist(r, a) + Expect(artist.CoverArt).To(BeEmpty()) + Expect(artist.ArtistImageUrl).To(BeEmpty()) + + id3 := toArtistID3(r, a) + Expect(id3.CoverArt).To(BeEmpty()) + Expect(id3.ArtistImageUrl).To(BeEmpty()) + }) + It("emits an imageUrl when unresolved", func() { + a := model.Artist{ID: "ar-1"} + Expect(toArtist(r, a).ArtistImageUrl).ToNot(BeEmpty()) + }) + }) + }) + Describe("osChildFromMediaFile", func() { var mf model.MediaFile var ctx context.Context diff --git a/server/subsonic/playlists.go b/server/subsonic/playlists.go index 17ba1b2c9..bd53528d9 100644 --- a/server/subsonic/playlists.go +++ b/server/subsonic/playlists.go @@ -151,7 +151,7 @@ func (api *Router) buildPlaylist(ctx context.Context, p model.Playlist) response pls.Comment = p.Comment pls.Owner = p.OwnerName pls.Public = p.Public - pls.CoverArt = p.CoverArtID().String() + pls.CoverArt = coverArtOrEmpty(p.CoverArtID(), p.ImageAbsent) pls.OpenSubsonicPlaylist = buildOSPlaylist(ctx, p) return pls diff --git a/server/subsonic/playlists_test.go b/server/subsonic/playlists_test.go index f0a2f8ac5..a7c9e2ec7 100644 --- a/server/subsonic/playlists_test.go +++ b/server/subsonic/playlists_test.go @@ -129,6 +129,19 @@ var _ = Describe("buildPlaylist", func() { }) }) + Context("artwork emission", func() { + It("suffixes coverArt with the content hash when resolved", func() { + playlist.ImageHash = "0123456789abcdef" + result := router.buildPlaylist(ctx, playlist) + Expect(result.CoverArt).To(Equal("pl-pls-1_0123456789abcdef")) + }) + It("omits coverArt when known absent", func() { + playlist.ImageAbsent = true + result := router.buildPlaylist(ctx, playlist) + Expect(result.CoverArt).To(BeEmpty()) + }) + }) + Context("with legacy client", func() { BeforeEach(func() { conf.Server.Subsonic.LegacyClients = "legacy-client" diff --git a/server/subsonic/radio.go b/server/subsonic/radio.go index 4fbd6a53d..1fb266f1d 100644 --- a/server/subsonic/radio.go +++ b/server/subsonic/radio.go @@ -74,10 +74,10 @@ func (api *Router) GetInternetRadios(r *http.Request) (*responses.Subsonic, erro if strings.Contains(conf.Server.Subsonic.LegacyClients, player.Client) { continue } - // Add coverArt if not legacy client + // Add coverArt if not legacy client; only radios have an uploaded image as their sole art source var coverArt string if g.UploadedImage != "" { - coverArt = g.CoverArtID().String() + coverArt = coverArtOrEmpty(g.CoverArtID(), g.ImageAbsent) } res[i].OpenSubsonicRadio = &responses.OpenSubsonicRadio{ CoverArt: coverArt, diff --git a/server/subsonic/radio_test.go b/server/subsonic/radio_test.go index 2e527c840..aa805abbf 100644 --- a/server/subsonic/radio_test.go +++ b/server/subsonic/radio_test.go @@ -75,6 +75,21 @@ var _ = Describe("Radio", func() { Expect(response.InternetRadioStations.Radios[1].OpenSubsonicRadio).ToNot(BeNil()) Expect(response.InternetRadioStations.Radios[1].CoverArt).To(BeEmpty()) }) + + It("suffixes coverArt with the content hash when resolved and omits it when known absent", func() { + radioRepo.All = model.Radios{ + {ID: "rd-1", Name: "Radio 1", UploadedImage: "rd-1_cover.jpg", ItemImage: model.ItemImage{ImageHash: "0123456789abcdef"}}, + {ID: "rd-2", Name: "Radio 2", UploadedImage: "rd-2_cover.jpg", ItemImage: model.ItemImage{ImageAbsent: true}}, + } + r := httptest.NewRequest("GET", "/rest/getInternetRadios", nil) + r = r.WithContext(ctx) + + response, err := api.GetInternetRadios(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("ra-rd-1_0123456789abcdef")) + Expect(response.InternetRadioStations.Radios[1].CoverArt).To(BeEmpty()) + }) }) Context("with a legacy client", func() { diff --git a/server/subsonic/searching.go b/server/subsonic/searching.go index 5d4989ae5..cc787f041 100644 --- a/server/subsonic/searching.go +++ b/server/subsonic/searching.go @@ -110,11 +110,13 @@ func (api *Router) Search2(r *http.Request) (*responses.Subsonic, error) { searchResult2 := &responses.SearchResult2{} searchResult2.Artist = slice.Map(as, func(artist model.Artist) responses.Artist { a := responses.Artist{ - Id: artist.ID, - Name: artist.Name, - UserRating: int32(artist.Rating), - CoverArt: artist.CoverArtID().String(), - ArtistImageUrl: publicurl.ImageURL(r, artist.CoverArtID(), 600), + Id: artist.ID, + Name: artist.Name, + UserRating: int32(artist.Rating), + CoverArt: coverArtOrEmpty(artist.CoverArtID(), artist.ImageAbsent), + } + if !artist.ImageAbsent { + a.ArtistImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 600) } if artist.Starred { a.Starred = artist.StarredAt diff --git a/server/subsonic/searching_test.go b/server/subsonic/searching_test.go index 4e72bd2e6..d31a50cfa 100644 --- a/server/subsonic/searching_test.go +++ b/server/subsonic/searching_test.go @@ -1,6 +1,8 @@ package subsonic import ( + "net/http" + "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/model" @@ -209,4 +211,32 @@ var _ = Describe("Search", func() { }) }) }) + + Describe("artwork emission", func() { + newSearchRequest := func() *http.Request { + r := newGetRequest("query=test") + ctx := request.WithUser(r.Context(), model.User{ID: "user1", Libraries: []model.Library{{ID: 1}}}) + return r.WithContext(ctx) + } + + It("omits coverArt and imageUrl for a known-absent artist", func() { + mockArtistRepo.SetData(model.Artists{ + {ID: "ar-1", Name: "Absent Artist", ItemImage: model.ItemImage{ImageAbsent: true}}, + }) + resp, err := router.Search2(newSearchRequest()) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.SearchResult2.Artist).To(HaveLen(1)) + Expect(resp.SearchResult2.Artist[0].CoverArt).To(BeEmpty()) + Expect(resp.SearchResult2.Artist[0].ArtistImageUrl).To(BeEmpty()) + }) + + It("emits coverArt and imageUrl for an unresolved artist", func() { + mockArtistRepo.SetData(model.Artists{{ID: "ar-1", Name: "Unresolved Artist"}}) + resp, err := router.Search2(newSearchRequest()) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.SearchResult2.Artist).To(HaveLen(1)) + Expect(resp.SearchResult2.Artist[0].CoverArt).To(Equal("ar-ar-1")) + Expect(resp.SearchResult2.Artist[0].ArtistImageUrl).ToNot(BeEmpty()) + }) + }) }) From 937e58e5fb6b63fb62b10a967b7b5b9045011c72 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 22:20:14 -0400 Subject: [PATCH 64/87] refactor(artwork): delete the legacy reader chain, cache warmer, and provider image methods --- cmd/wire_gen.go | 39 +- core/artwork/artwork.go | 134 ---- core/artwork/artwork_internal_test.go | 628 --------------- core/artwork/artwork_suite_test.go | 5 +- core/artwork/artwork_test.go | 57 -- core/artwork/benchmark_e2e_test.go | 189 ----- core/artwork/cache_warmer.go | 162 ---- core/artwork/cache_warmer_test.go | 245 ------ core/artwork/{reader_disc.go => disc.go} | 58 +- .../{reader_disc_test.go => disc_test.go} | 0 core/artwork/e2e/album_test.go | 469 ----------- core/artwork/e2e/artist_test.go | 167 ---- core/artwork/e2e/disc_test.go | 371 --------- core/artwork/e2e/helpers_test.go | 187 ----- core/artwork/e2e/mediafile_test.go | 110 --- core/artwork/e2e/playlist_test.go | 158 ---- core/artwork/e2e/radio_test.go | 42 - core/artwork/e2e/suite_test.go | 120 --- core/artwork/e2e/testdata/embedded_art.mp3 | Bin 64223 -> 0 bytes .../{reader_album.go => folders_album.go} | 88 --- .../{reader_artist.go => folders_artist.go} | 147 ---- core/artwork/image_cache.go | 20 +- core/artwork/playlist_cover.go | 100 +++ core/artwork/reader_album_test.go | 454 ----------- core/artwork/reader_artist_test.go | 748 ------------------ core/artwork/reader_mediafile.go | 82 -- core/artwork/reader_playlist.go | 269 ------- core/artwork/reader_radio.go | 40 - core/artwork/reader_radio_test.go | 84 -- core/artwork/reader_resized.go | 87 -- core/artwork/reader_resized_test.go | 176 ----- core/artwork/serving.go | 4 +- core/artwork/sources.go | 40 - core/artwork/testhelpers_test.go | 37 + core/artwork/wire_providers.go | 2 - core/external/provider.go | 93 --- core/external/provider_albumimage_test.go | 365 --------- core/external/provider_artistimage_test.go | 469 ----------- persistence/e2e/e2e_suite_test.go | 3 +- scanner/controller.go | 9 +- scanner/controller_test.go | 3 +- scanner/phase_1_folders.go | 20 +- scanner/phase_4_playlists.go | 6 +- scanner/phase_4_playlists_test.go | 5 +- scanner/scanner.go | 6 +- scanner/scanner_benchmark_test.go | 3 +- scanner/scanner_multilibrary_test.go | 3 +- scanner/scanner_selective_test.go | 3 +- scanner/scanner_test.go | 3 +- server/subsonic/e2e/e2e_suite_test.go | 14 +- .../e2e/subsonic_multilibrary_test.go | 3 +- tests/harness/harness.go | 3 +- 52 files changed, 184 insertions(+), 6346 deletions(-) delete mode 100644 core/artwork/artwork.go delete mode 100644 core/artwork/artwork_internal_test.go delete mode 100644 core/artwork/artwork_test.go delete mode 100644 core/artwork/benchmark_e2e_test.go delete mode 100644 core/artwork/cache_warmer.go delete mode 100644 core/artwork/cache_warmer_test.go rename core/artwork/{reader_disc.go => disc.go} (79%) rename core/artwork/{reader_disc_test.go => disc_test.go} (100%) delete mode 100644 core/artwork/e2e/album_test.go delete mode 100644 core/artwork/e2e/artist_test.go delete mode 100644 core/artwork/e2e/disc_test.go delete mode 100644 core/artwork/e2e/helpers_test.go delete mode 100644 core/artwork/e2e/mediafile_test.go delete mode 100644 core/artwork/e2e/playlist_test.go delete mode 100644 core/artwork/e2e/radio_test.go delete mode 100644 core/artwork/e2e/suite_test.go delete mode 100644 core/artwork/e2e/testdata/embedded_art.mp3 rename core/artwork/{reader_album.go => folders_album.go} (63%) rename core/artwork/{reader_artist.go => folders_artist.go} (52%) create mode 100644 core/artwork/playlist_cover.go delete mode 100644 core/artwork/reader_album_test.go delete mode 100644 core/artwork/reader_artist_test.go delete mode 100644 core/artwork/reader_mediafile.go delete mode 100644 core/artwork/reader_playlist.go delete mode 100644 core/artwork/reader_radio.go delete mode 100644 core/artwork/reader_radio_test.go delete mode 100644 core/artwork/reader_resized.go delete mode 100644 core/artwork/reader_resized_test.go create mode 100644 core/artwork/testhelpers_test.go delete mode 100644 core/external/provider_albumimage_test.go delete mode 100644 core/external/provider_artistimage_test.go diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index e7b961cf0..cbd903e3c 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -68,18 +68,11 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router { imageUploadService := core.NewImageUploadService() playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) insights := metrics.GetInstance(dataStore) - fileCache := artwork.GetImageCache() - fFmpeg := ffmpeg.New() broker := events.GetBroker() metricsMetrics := metrics.GetPrometheusInstance(dataStore) - manager := plugins.GetManager(dataStore, broker, metricsMetrics) - agentsAgents := agents.GetAgents(dataStore, manager) - matcherMatcher := matcher.New(dataStore) - provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) - artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) - cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) - modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics) + modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics) watcher := scanner.GetWatcher(dataStore, modelScanner) + manager := plugins.GetManager(dataStore, broker, metricsMetrics) library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager) user := core.NewUser(dataStore, manager) maintenance := core.NewMaintenance(dataStore) @@ -105,11 +98,9 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { agentsAgents := agents.GetAgents(dataStore, manager) matcherMatcher := matcher.New(dataStore) provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) - artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) - cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) imageUploadService := core.NewImageUploadService() playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) - modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics) + modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics) playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) playbackServer := playback.GetInstance(dataStore) lyricsLyrics := lyrics.NewLyrics(dataStore, manager) @@ -191,38 +182,22 @@ func CreatePrometheus() metrics.Metrics { func CreateScanner(ctx context.Context) model.Scanner { sqlDB := db.Db() dataStore := persistence.New(sqlDB) - fileCache := artwork.GetImageCache() - fFmpeg := ffmpeg.New() broker := events.GetBroker() - metricsMetrics := metrics.GetPrometheusInstance(dataStore) - manager := plugins.GetManager(dataStore, broker, metricsMetrics) - agentsAgents := agents.GetAgents(dataStore, manager) - matcherMatcher := matcher.New(dataStore) - provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) - artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) - cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) imageUploadService := core.NewImageUploadService() playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) - modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics) + metricsMetrics := metrics.GetPrometheusInstance(dataStore) + modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics) return modelScanner } func CreateScanWatcher(ctx context.Context) scanner.Watcher { sqlDB := db.Db() dataStore := persistence.New(sqlDB) - fileCache := artwork.GetImageCache() - fFmpeg := ffmpeg.New() broker := events.GetBroker() - metricsMetrics := metrics.GetPrometheusInstance(dataStore) - manager := plugins.GetManager(dataStore, broker, metricsMetrics) - agentsAgents := agents.GetAgents(dataStore, manager) - matcherMatcher := matcher.New(dataStore) - provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) - artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) - cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) imageUploadService := core.NewImageUploadService() playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) - modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics) + metricsMetrics := metrics.GetPrometheusInstance(dataStore) + modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics) watcher := scanner.GetWatcher(dataStore, modelScanner) return watcher } diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go deleted file mode 100644 index b8c395c12..000000000 --- a/core/artwork/artwork.go +++ /dev/null @@ -1,134 +0,0 @@ -package artwork - -import ( - "context" - "errors" - _ "image/gif" - "io" - "time" - - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/external" - "github.com/navidrome/navidrome/core/ffmpeg" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/resources" - "github.com/navidrome/navidrome/utils/cache" - _ "golang.org/x/image/webp" -) - -var ErrUnavailable = errors.New("artwork unavailable") - -type Artwork interface { - Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (io.ReadCloser, time.Time, error) - GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) -} - -func NewArtwork(ds model.DataStore, cache cache.FileCache, ffmpeg ffmpeg.FFmpeg, provider external.Provider) Artwork { - return &artwork{ds: ds, cache: cache, ffmpeg: ffmpeg, provider: provider} -} - -type artwork struct { - ds model.DataStore - cache cache.FileCache - ffmpeg ffmpeg.FFmpeg - provider external.Provider -} - -type artworkReader interface { - cache.Item - LastUpdated() time.Time - Reader(ctx context.Context) (io.ReadCloser, string, error) -} - -func (a *artwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (reader io.ReadCloser, lastUpdate time.Time, err error) { - artID, err := a.getArtworkId(ctx, id) - if err == nil { - reader, lastUpdate, err = a.Get(ctx, artID, size, square) - } - if errors.Is(err, ErrUnavailable) { - if artID.Kind == model.KindArtistArtwork { - reader, _ = resources.FS().Open(consts.PlaceholderArtistArt) - } else { - reader, _ = resources.FS().Open(consts.PlaceholderAlbumArt) - } - return reader, consts.ServerStart, nil - } - return reader, lastUpdate, err -} - -func (a *artwork) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (reader io.ReadCloser, lastUpdate time.Time, err error) { - artReader, err := a.getArtworkReader(ctx, artID, size, square) - if err != nil { - return nil, time.Time{}, err - } - - r, err := a.cache.Get(ctx, artReader) - if err != nil { - if !errors.Is(err, context.Canceled) && !errors.Is(err, ErrUnavailable) { - log.Error(ctx, "Error accessing image cache", "id", artID, "size", size, err) - } - return nil, time.Time{}, err - } - return r, artReader.LastUpdated(), nil -} - -type coverArtGetter interface { - CoverArtID() model.ArtworkID -} - -func (a *artwork) getArtworkId(ctx context.Context, id string) (model.ArtworkID, error) { - if id == "" { - return model.ArtworkID{}, ErrUnavailable - } - artID, err := model.ParseArtworkID(id) - if err == nil { - return artID, nil - } - - log.Trace(ctx, "ArtworkID invalid. Trying to figure out kind based on the ID", "id", id) - entity, err := model.GetEntityByID(ctx, a.ds, id) - if err != nil { - return model.ArtworkID{}, err - } - if e, ok := entity.(coverArtGetter); ok { - artID = e.CoverArtID() - } - switch e := entity.(type) { - case *model.Artist: - log.Trace(ctx, "ID is for an Artist", "id", id, "name", e.Name, "artist", e.Name) - case *model.Album: - log.Trace(ctx, "ID is for an Album", "id", id, "name", e.Name, "artist", e.AlbumArtist) - case *model.MediaFile: - log.Trace(ctx, "ID is for a MediaFile", "id", id, "title", e.Title, "album", e.Album) - case *model.Playlist: - log.Trace(ctx, "ID is for a Playlist", "id", id, "name", e.Name) - } - return artID, nil -} - -func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, size int, square bool) (artworkReader, error) { - var artReader artworkReader - var err error - if size > 0 || square { - artReader, err = resizedFromOriginal(ctx, a, artID, size, square) - } else { - switch artID.Kind { - case model.KindArtistArtwork: - artReader, err = newArtistArtworkReader(ctx, a, artID, a.provider) - case model.KindAlbumArtwork: - artReader, err = newAlbumArtworkReader(ctx, a, artID, a.provider) - case model.KindMediaFileArtwork: - artReader, err = newMediafileArtworkReader(ctx, a, artID) - case model.KindPlaylistArtwork: - artReader, err = newPlaylistArtworkReader(ctx, a, artID) - case model.KindDiscArtwork: - artReader, err = newDiscArtworkReader(ctx, a, artID) - case model.KindRadioArtwork: - artReader, err = newRadioArtworkReader(ctx, a, artID) - default: - return nil, ErrUnavailable - } - } - return artReader, err -} diff --git a/core/artwork/artwork_internal_test.go b/core/artwork/artwork_internal_test.go deleted file mode 100644 index f5312361d..000000000 --- a/core/artwork/artwork_internal_test.go +++ /dev/null @@ -1,628 +0,0 @@ -package artwork - -import ( - "context" - "errors" - "image" - "image/jpeg" - "image/png" - "io" - "os" - "path/filepath" - "time" - - _ "github.com/gen2brain/webp" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Artwork", func() { - var aw *artwork - var ds model.DataStore - var ffmpeg *tests.MockFFmpeg - var folderRepo *fakeFolderRepo - ctx := log.NewContext(context.TODO()) - var alOnlyEmbed, alEmbedNotFound, alOnlyExternal, alExternalNotFound, alMultipleCovers, alSingleDisc model.Album - var arMultipleCovers model.Artist - var mfWithEmbed, mfAnotherWithEmbed, mfWithoutEmbed, mfCorruptedCover model.MediaFile - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.ImageCacheSize = "0" // Disable cache - conf.Server.CoverArtPriority = "folder.*, cover.*, embedded , front.*" - - folderRepo = &fakeFolderRepo{} - libRepo := &tests.MockLibraryRepo{} - repoRoot, _ := os.Getwd() - libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) - ds = &tests.MockDataStore{ - MockedTranscoding: &tests.MockTranscodingRepo{}, - MockedFolder: folderRepo, - MockedLibrary: libRepo, - } - // Paths use forward slashes because the scanner stores fs.FS-relative paths in the DB. - alOnlyEmbed = model.Album{ID: "222", Name: "Only embed", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}} - alEmbedNotFound = model.Album{ID: "333", Name: "Embed not found", EmbedArtPath: "tests/fixtures/NON_EXISTENT.mp3", FolderIDs: []string{"f1"}} - alOnlyExternal = model.Album{ID: "444", Name: "Only external", FolderIDs: []string{"f1"}, Discs: model.Discs{1: "", 2: ""}} - alExternalNotFound = model.Album{ID: "555", Name: "External not found", FolderIDs: []string{"f2"}} - alSingleDisc = model.Album{ID: "888", Name: "Single disc", FolderIDs: []string{"f1"}, Discs: model.Discs{1: ""}} - arMultipleCovers = model.Artist{ID: "777", Name: "All options"} - alMultipleCovers = model.Album{ - ID: "666", - Name: "All options", - EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", - FolderIDs: []string{"f1"}, - AlbumArtistID: "777", - } - mfWithEmbed = model.MediaFile{ID: "22", Path: "tests/fixtures/test.mp3", HasCoverArt: true, AlbumID: "222"} - mfAnotherWithEmbed = model.MediaFile{ID: "23", Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: true, AlbumID: "666"} - mfWithoutEmbed = model.MediaFile{ID: "44", Path: "tests/fixtures/test.ogg", AlbumID: "444"} - mfCorruptedCover = model.MediaFile{ID: "45", Path: "tests/fixtures/test.ogg", HasCoverArt: true, AlbumID: "444"} - - cache := GetImageCache() - ffmpeg = tests.NewMockFFmpeg("content from ffmpeg") - aw = NewArtwork(ds, cache, ffmpeg, nil).(*artwork) - }) - - Describe("albumArtworkReader", func() { - Context("ID not found", func() { - It("returns ErrNotFound if album is not in the DB", func() { - _, err := newAlbumArtworkReader(ctx, aw, model.MustParseArtworkID("al-NOT-FOUND"), nil) - Expect(err).To(MatchError(model.ErrNotFound)) - }) - }) - Context("Embed images", func() { - BeforeEach(func() { - folderRepo.result = nil - ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ - alOnlyEmbed, - alEmbedNotFound, - }) - }) - It("returns embed cover", func() { - aw, err := newAlbumArtworkReader(ctx, aw, alOnlyEmbed.CoverArtID(), nil) - Expect(err).ToNot(HaveOccurred()) - _, path, err := aw.Reader(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal("tests/fixtures/artist/an-album/test.mp3")) - }) - It("returns ErrUnavailable if embed path is not available", func() { - ffmpeg.Error = errors.New("not available") - aw, err := newAlbumArtworkReader(ctx, aw, alEmbedNotFound.CoverArtID(), nil) - Expect(err).ToNot(HaveOccurred()) - _, _, err = aw.Reader(ctx) - Expect(err).To(MatchError(ErrUnavailable)) - }) - }) - Context("External images", func() { - BeforeEach(func() { - folderRepo.result = []model.Folder{} - ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ - alOnlyExternal, - alExternalNotFound, - }) - }) - It("returns external cover", func() { - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"front.png"}, - }} - aw, err := newAlbumArtworkReader(ctx, aw, alOnlyExternal.CoverArtID(), nil) - Expect(err).ToNot(HaveOccurred()) - _, path, err := aw.Reader(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal("tests/fixtures/artist/an-album/front.png")) - }) - It("returns ErrUnavailable if external file is not available", func() { - folderRepo.result = []model.Folder{} - aw, err := newAlbumArtworkReader(ctx, aw, alExternalNotFound.CoverArtID(), nil) - Expect(err).ToNot(HaveOccurred()) - _, _, err = aw.Reader(ctx) - Expect(err).To(MatchError(ErrUnavailable)) - }) - }) - Context("Multiple covers", func() { - BeforeEach(func() { - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"cover.jpg", "front.png", "artist.png"}, - }} - ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ - alMultipleCovers, - }) - }) - DescribeTable("CoverArtPriority", - func(priority string, expected string) { - conf.Server.CoverArtPriority = priority - aw, err := newAlbumArtworkReader(ctx, aw, alMultipleCovers.CoverArtID(), nil) - Expect(err).ToNot(HaveOccurred()) - _, path, err := aw.Reader(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal(expected)) - }, - Entry(nil, " folder.* , cover.*,embedded,front.*", "tests/fixtures/artist/an-album/cover.jpg"), - Entry(nil, "front.* , cover.*, embedded ,folder.*", "tests/fixtures/artist/an-album/front.png"), - Entry(nil, " embedded , front.* , cover.*,folder.*", "tests/fixtures/artist/an-album/test.mp3"), - ) - }) - Context("LastUpdated", func() { - // Regression test for #5377: LastUpdated feeds the HTTP Last-Modified header. - // It must return max(album.UpdatedAt, ImagesUpdatedAt) so browsers revalidate - // cached cover art when only the image file changes. - now := time.Now().Truncate(time.Second) - DescribeTable("returns the max of album.UpdatedAt and ImagesUpdatedAt", - func(albumUpdatedAt, imagesUpdatedAt, expected time.Time) { - album := model.Album{ID: "al1", UpdatedAt: albumUpdatedAt} - folderRepo.result = []model.Folder{{ImagesUpdatedAt: imagesUpdatedAt}} - ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{album}) - - ar, err := newAlbumArtworkReader(ctx, aw, album.CoverArtID(), nil) - Expect(err).ToNot(HaveOccurred()) - Expect(ar.LastUpdated()).To(Equal(expected)) - }, - Entry("album newer than images", now, now.Add(-1*time.Hour), now), - Entry("images newer than album", now.Add(-24*time.Hour), now.Add(-1*time.Hour), now.Add(-1*time.Hour)), - Entry("equal timestamps", now, now, now), - ) - }) - }) - Describe("discArtworkReader", func() { - Context("LastUpdated", func() { - // Regression test for #5377: same bug as albumArtworkReader — disc covers - // must also revalidate when the image file changes, not only when media files do. - now := time.Now().Truncate(time.Second) - DescribeTable("returns the max of album.UpdatedAt and ImagesUpdatedAt", - func(albumUpdatedAt, imagesUpdatedAt, expected time.Time) { - album := model.Album{ID: "al1", UpdatedAt: albumUpdatedAt} - folderRepo.result = []model.Folder{{ImagesUpdatedAt: imagesUpdatedAt}} - ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{album}) - ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "mf1", AlbumID: "al1", DiscNumber: 1, Path: "tests/fixtures/test.mp3"}, - }) - - artID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("al1", 1), nil) - dr, err := newDiscArtworkReader(ctx, aw, artID) - Expect(err).ToNot(HaveOccurred()) - Expect(dr.LastUpdated()).To(Equal(expected)) - }, - Entry("album newer than images", now, now.Add(-1*time.Hour), now), - Entry("images newer than album", now.Add(-24*time.Hour), now.Add(-1*time.Hour), now.Add(-1*time.Hour)), - Entry("equal timestamps", now, now, now), - ) - }) - }) - Describe("artistArtworkReader", func() { - Context("Multiple covers", func() { - BeforeEach(func() { - repoRoot, err := os.Getwd() - Expect(err).ToNot(HaveOccurred()) - folderRepo.result = []model.Folder{{ - LibraryPath: testFileLibPath(repoRoot), - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"artist.png"}, - }} - ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{ - arMultipleCovers, - }) - ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ - alMultipleCovers, - }) - ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - mfAnotherWithEmbed, - }) - }) - DescribeTable("ArtistArtPriority", - func(priority string, expected string) { - conf.Server.ArtistArtPriority = priority - aw, err := newArtistArtworkReader(ctx, aw, arMultipleCovers.CoverArtID(), nil) - Expect(err).ToNot(HaveOccurred()) - _, path, err := aw.Reader(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(filepath.ToSlash(path)).To(HaveSuffix(expected)) - }, - Entry(nil, " folder.* , artist.*,album/artist.*", "tests/fixtures/artist/artist.jpg"), - Entry(nil, "album/artist.*, folder.*,artist.*", "tests/fixtures/artist/an-album/artist.png"), - ) - }) - }) - Describe("mediafileArtworkReader", func() { - Context("ID not found", func() { - It("returns ErrNotFound if mediafile is not in the DB", func() { - _, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-NOT-FOUND")) - Expect(err).To(MatchError(model.ErrNotFound)) - }) - }) - Context("Embed images", func() { - BeforeEach(func() { - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"front.png"}, - }} - ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ - alOnlyEmbed, - alOnlyExternal, - alSingleDisc, - }) - ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - mfWithEmbed, - mfWithoutEmbed, - mfCorruptedCover, - }) - }) - It("returns embed cover", func() { - aw, err := newMediafileArtworkReader(ctx, aw, mfWithEmbed.CoverArtID()) - Expect(err).ToNot(HaveOccurred()) - _, path, err := aw.Reader(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal("tests/fixtures/test.mp3")) - }) - It("returns embed cover if successfully extracted by ffmpeg", func() { - aw, err := newMediafileArtworkReader(ctx, aw, mfCorruptedCover.CoverArtID()) - Expect(err).ToNot(HaveOccurred()) - r, path, err := aw.Reader(ctx) - Expect(err).ToNot(HaveOccurred()) - data, _ := io.ReadAll(r) - Expect(data).ToNot(BeEmpty()) - Expect(path).To(Equal("tests/fixtures/test.ogg")) - }) - It("returns album cover if cannot read embed artwork", func() { - // Force fromTag to fail - mfCorruptedCover.Path = "tests/fixtures/DOES_NOT_EXIST.ogg" - Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Put(&mfCorruptedCover)).To(Succeed()) - // Simulate ffmpeg error - ffmpeg.Error = errors.New("not available") - - aw, err := newMediafileArtworkReader(ctx, aw, mfCorruptedCover.CoverArtID()) - Expect(err).ToNot(HaveOccurred()) - _, path, err := aw.Reader(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal("al-444")) - }) - It("returns album cover if media file has no cover art", func() { - aw, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-"+mfWithoutEmbed.ID)) - Expect(err).ToNot(HaveOccurred()) - _, path, err := aw.Reader(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal("al-444")) - }) - It("falls back to disc cover art when media file has a disc number on a multi-disc album", func() { - mfWithDisc := model.MediaFile{ID: "46", Path: "tests/fixtures/test.ogg", AlbumID: "444", DiscNumber: 2} - Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Put(&mfWithDisc)).To(Succeed()) - - aw, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-"+mfWithDisc.ID)) - Expect(err).ToNot(HaveOccurred()) - _, path, err := aw.Reader(ctx) - Expect(err).ToNot(HaveOccurred()) - // Should fall back to disc art, which itself falls back to album art - Expect(path).To(Equal("dc-444:2")) - }) - It("falls back to album cover art for single-disc albums even with a disc number", func() { - mfOnSingleDisc := model.MediaFile{ID: "47", Path: "tests/fixtures/test.ogg", AlbumID: "888", DiscNumber: 1} - Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Put(&mfOnSingleDisc)).To(Succeed()) - - aw, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-"+mfOnSingleDisc.ID)) - Expect(err).ToNot(HaveOccurred()) - _, path, err := aw.Reader(ctx) - Expect(err).ToNot(HaveOccurred()) - // Single-disc album should skip disc art and go straight to album art - Expect(path).To(Equal("al-888")) - }) - }) - }) - Describe("playlistArtworkReader", func() { - Describe("findPlaylistSidecarPath", func() { - It("discovers sidecar image next to playlist file", func() { - tmpDir := GinkgoT().TempDir() - plsPath := filepath.Join(tmpDir, "MyPlaylist.m3u") - imgPath := filepath.Join(tmpDir, "MyPlaylist.jpg") - Expect(os.WriteFile(plsPath, []byte("#EXTM3U\n"), 0600)).To(Succeed()) - Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed()) - - result := findPlaylistSidecarPath(GinkgoT().Context(), plsPath) - Expect(result).To(Equal(imgPath)) - }) - - It("returns empty string when no sidecar image exists", func() { - tmpDir := GinkgoT().TempDir() - plsPath := filepath.Join(tmpDir, "MyPlaylist.m3u") - Expect(os.WriteFile(plsPath, []byte("#EXTM3U\n"), 0600)).To(Succeed()) - - result := findPlaylistSidecarPath(GinkgoT().Context(), plsPath) - Expect(result).To(BeEmpty()) - }) - - It("returns empty string when playlist has no path", func() { - result := findPlaylistSidecarPath(GinkgoT().Context(), "") - Expect(result).To(BeEmpty()) - }) - - It("finds sidecar with different case base name", func() { - tmpDir := GinkgoT().TempDir() - plsPath := filepath.Join(tmpDir, "myplaylist.m3u") - imgPath := filepath.Join(tmpDir, "MyPlaylist.jpg") - Expect(os.WriteFile(plsPath, []byte("#EXTM3U\n"), 0600)).To(Succeed()) - Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed()) - - result := findPlaylistSidecarPath(GinkgoT().Context(), plsPath) - Expect(result).To(Equal(imgPath)) - }) - }) - - Describe("fromPlaylistExternalImage", func() { - It("opens local path from ExternalImageURL", func() { - tmpDir := GinkgoT().TempDir() - imgPath := filepath.Join(tmpDir, "cover.jpg") - Expect(os.WriteFile(imgPath, []byte("external image data"), 0600)).To(Succeed()) - - reader := &playlistArtworkReader{ - pl: model.Playlist{ExternalImageURL: imgPath}, - } - r, path, err := reader.fromPlaylistExternalImage(ctx)() - Expect(err).ToNot(HaveOccurred()) - Expect(r).ToNot(BeNil()) - Expect(path).To(Equal(imgPath)) - data, _ := io.ReadAll(r) - Expect(string(data)).To(Equal("external image data")) - r.Close() - }) - - It("returns nil when ExternalImageURL is empty", func() { - reader := &playlistArtworkReader{ - pl: model.Playlist{ExternalImageURL: ""}, - } - r, path, err := reader.fromPlaylistExternalImage(ctx)() - Expect(err).ToNot(HaveOccurred()) - Expect(r).To(BeNil()) - Expect(path).To(BeEmpty()) - }) - - It("returns error when local file does not exist", func() { - reader := &playlistArtworkReader{ - pl: model.Playlist{ExternalImageURL: "/non/existent/path/cover.jpg"}, - } - r, _, err := reader.fromPlaylistExternalImage(ctx)() - Expect(err).To(HaveOccurred()) - Expect(r).To(BeNil()) - }) - - It("skips HTTP URL when EnableM3UExternalAlbumArt is false", func() { - conf.Server.EnableM3UExternalAlbumArt = false - - reader := &playlistArtworkReader{ - pl: model.Playlist{ExternalImageURL: "https://example.com/cover.jpg"}, - } - r, path, err := reader.fromPlaylistExternalImage(ctx)() - Expect(err).ToNot(HaveOccurred()) - Expect(r).To(BeNil()) - Expect(path).To(BeEmpty()) - }) - - It("still opens local path when EnableM3UExternalAlbumArt is false", func() { - conf.Server.EnableM3UExternalAlbumArt = false - - tmpDir := GinkgoT().TempDir() - imgPath := filepath.Join(tmpDir, "cover.jpg") - Expect(os.WriteFile(imgPath, []byte("local image"), 0600)).To(Succeed()) - - reader := &playlistArtworkReader{ - pl: model.Playlist{ExternalImageURL: imgPath}, - } - r, path, err := reader.fromPlaylistExternalImage(ctx)() - Expect(err).ToNot(HaveOccurred()) - Expect(r).ToNot(BeNil()) - Expect(path).To(Equal(imgPath)) - r.Close() - }) - }) - }) - - Describe("resizedArtworkReader", func() { - BeforeEach(func() { - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"cover.jpg", "front.png"}, - }} - ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ - alMultipleCovers, - }) - }) - When("Square is false", func() { - It("returns PNG if original image is a PNG", func() { - conf.Server.CoverArtPriority = "front.png" - r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false) - Expect(err).ToNot(HaveOccurred()) - - img, format, err := image.Decode(r) - Expect(err).ToNot(HaveOccurred()) - Expect(format).To(Equal("png")) - Expect(img.Bounds().Size().X).To(Equal(15)) - Expect(img.Bounds().Size().Y).To(Equal(15)) - }) - It("returns JPEG if original image is not a PNG", func() { - conf.Server.CoverArtPriority = "cover.jpg" - r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false) - Expect(err).ToNot(HaveOccurred()) - - img, format, err := image.Decode(r) - Expect(format).To(Equal("jpeg")) - Expect(err).ToNot(HaveOccurred()) - Expect(img.Bounds().Size().X).To(Equal(200)) - Expect(img.Bounds().Size().Y).To(Equal(200)) - }) - }) - When("When square is true", func() { - var alCover model.Album - - DescribeTable("resize", - func(srcFormat string, expectedFormat string, landscape bool, size int) { - coverFileName := "cover." + srcFormat - dirName := createImage(srcFormat, landscape, size) - alCover = model.Album{ - ID: "444", - Name: "Only external", - FolderIDs: []string{"tmp"}, - } - folderRepo.result = []model.Folder{{ImageFiles: []string{coverFileName}}} - rootLibRepo := &tests.MockLibraryRepo{} - rootLibRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(dirName)}}) - ds.(*tests.MockDataStore).MockedLibrary = rootLibRepo - ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ - alCover, - }) - - conf.Server.CoverArtPriority = coverFileName - r, _, err := aw.Get(context.Background(), alCover.CoverArtID(), size, true) - Expect(err).ToNot(HaveOccurred()) - - img, format, err := image.Decode(r) - Expect(err).ToNot(HaveOccurred()) - Expect(format).To(Equal(expectedFormat)) - Expect(img.Bounds().Size().X).To(Equal(size)) - Expect(img.Bounds().Size().Y).To(Equal(size)) - }, - Entry("portrait png image", "png", "png", false, 200), - Entry("landscape png image", "png", "png", true, 200), - Entry("portrait jpg image", "jpg", "png", false, 200), - Entry("landscape jpg image", "jpg", "png", true, 200), - ) - }) - When("EnableWebPEncoding is true and square is false", func() { - BeforeEach(func() { - conf.Server.EnableWebPEncoding = true - }) - It("returns WebP even if original image is a PNG", func() { - conf.Server.CoverArtPriority = "front.png" - r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false) - Expect(err).ToNot(HaveOccurred()) - - img, format, err := image.Decode(r) - Expect(err).ToNot(HaveOccurred()) - Expect(format).To(Equal("webp")) - Expect(img.Bounds().Size().X).To(Equal(15)) - Expect(img.Bounds().Size().Y).To(Equal(15)) - }) - It("returns WebP if original image is not a PNG", func() { - conf.Server.CoverArtPriority = "cover.jpg" - r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false) - Expect(err).ToNot(HaveOccurred()) - - img, format, err := image.Decode(r) - Expect(format).To(Equal("webp")) - Expect(err).ToNot(HaveOccurred()) - Expect(img.Bounds().Size().X).To(Equal(200)) - Expect(img.Bounds().Size().Y).To(Equal(200)) - }) - }) - When("EnableWebPEncoding is false and square is false", func() { - BeforeEach(func() { - conf.Server.EnableWebPEncoding = false - }) - It("returns PNG if original image is a PNG", func() { - conf.Server.CoverArtPriority = "front.png" - r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false) - Expect(err).ToNot(HaveOccurred()) - - img, format, err := image.Decode(r) - Expect(err).ToNot(HaveOccurred()) - Expect(format).To(Equal("png")) - Expect(img.Bounds().Size().X).To(Equal(15)) - Expect(img.Bounds().Size().Y).To(Equal(15)) - }) - It("returns JPEG if original image is a JPG", func() { - conf.Server.CoverArtPriority = "cover.jpg" - r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false) - Expect(err).ToNot(HaveOccurred()) - - img, format, err := image.Decode(r) - Expect(err).ToNot(HaveOccurred()) - Expect(format).To(Equal("jpeg")) - Expect(img.Bounds().Size().X).To(Equal(200)) - Expect(img.Bounds().Size().Y).To(Equal(200)) - }) - }) - When("EnableWebPEncoding is false and square is true", func() { - var alCover model.Album - - BeforeEach(func() { - conf.Server.EnableWebPEncoding = false - }) - It("returns PNG for square mode", func() { - dirName := createImage("png", false, 200) - alCover = model.Album{ - ID: "444", - Name: "Only external", - FolderIDs: []string{"tmp"}, - } - folderRepo.result = []model.Folder{{ImageFiles: []string{"cover.png"}}} - rootLibRepo := &tests.MockLibraryRepo{} - rootLibRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(dirName)}}) - ds.(*tests.MockDataStore).MockedLibrary = rootLibRepo - ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{alCover}) - - conf.Server.CoverArtPriority = "cover.png" - r, _, err := aw.Get(context.Background(), alCover.CoverArtID(), 200, true) - Expect(err).ToNot(HaveOccurred()) - - img, format, err := image.Decode(r) - Expect(err).ToNot(HaveOccurred()) - Expect(format).To(Equal("png")) - Expect(img.Bounds().Size().X).To(Equal(200)) - Expect(img.Bounds().Size().Y).To(Equal(200)) - }) - }) - When("Requested size is larger than original", func() { - It("clamps size to original dimensions", func() { - conf.Server.CoverArtPriority = "front.png" - // front.png is 16x16, requesting 99999 should return at original size - r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 99999, false) - Expect(err).ToNot(HaveOccurred()) - - img, _, err := image.Decode(r) - Expect(err).ToNot(HaveOccurred()) - // Should be clamped to original size (16), not 99999 - Expect(img.Bounds().Size().X).To(Equal(16)) - Expect(img.Bounds().Size().Y).To(Equal(16)) - }) - - It("clamps square size to original dimensions", func() { - conf.Server.CoverArtPriority = "front.png" - // front.png is 16x16, requesting 99999 with square should return 16x16 square - r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 99999, true) - Expect(err).ToNot(HaveOccurred()) - - img, _, err := image.Decode(r) - Expect(err).ToNot(HaveOccurred()) - // Should be clamped to original size (16), not 99999 - Expect(img.Bounds().Size().X).To(Equal(16)) - Expect(img.Bounds().Size().Y).To(Equal(16)) - }) - }) - }) -}) - -func createImage(format string, landscape bool, size int) string { - var img image.Image - - if landscape { - img = image.NewRGBA(image.Rect(0, 0, size, size/2)) - } else { - img = image.NewRGBA(image.Rect(0, 0, size/2, size)) - } - - tmpDir := GinkgoT().TempDir() - f, _ := os.Create(filepath.Join(tmpDir, "cover."+format)) - defer f.Close() - switch format { - case "png": - _ = png.Encode(f, img) - case "jpg": - _ = jpeg.Encode(f, img, &jpeg.Options{Quality: 75}) - } - - return tmpDir -} diff --git a/core/artwork/artwork_suite_test.go b/core/artwork/artwork_suite_test.go index e2a1a591c..ffb7d1148 100644 --- a/core/artwork/artwork_suite_test.go +++ b/core/artwork/artwork_suite_test.go @@ -28,9 +28,6 @@ func TestArtwork(t *testing.T) { goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*recursiveTree).dispatch"), goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*nonrecursiveTree).dispatch"), goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*nonrecursiveTree).internal"), - // The old cache_warmer.go starts a goroutine per NewCacheWarmer call with - // no shutdown path (dark-launch target for Phase 2, not touched here). - goleak.IgnoreTopFunction("github.com/navidrome/navidrome/core/artwork.(*cacheWarmer).waitSignal"), ) tests.Init(t, false) @@ -40,7 +37,7 @@ func TestArtwork(t *testing.T) { } // osDirFS wraps os.DirFS as a storage.MusicFS for integration tests. -// ReadTags is not used by albumArtworkReader, so it is left as a stub. +// ReadTags is not exercised by these tests, so it is left as a stub. type osDirFS struct{ fs.FS } func (o osDirFS) ReadTags(...string) (map[string]metadata.Info, error) { return nil, nil } diff --git a/core/artwork/artwork_test.go b/core/artwork/artwork_test.go deleted file mode 100644 index adddd0dc3..000000000 --- a/core/artwork/artwork_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package artwork_test - -import ( - "context" - "io" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/resources" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Artwork", func() { - var aw artwork.Artwork - var ds model.DataStore - var ffmpeg *tests.MockFFmpeg - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.ImageCacheSize = "0" // Disable cache - cache := artwork.GetImageCache() - ffmpeg = tests.NewMockFFmpeg("content from ffmpeg") - aw = artwork.NewArtwork(ds, cache, ffmpeg, nil) - }) - - Context("GetOrPlaceholder", func() { - Context("Empty ID", func() { - It("returns placeholder if album is not in the DB", func() { - r, _, err := aw.GetOrPlaceholder(context.Background(), "", 0, false) - Expect(err).ToNot(HaveOccurred()) - - ph, err := resources.FS().Open(consts.PlaceholderAlbumArt) - Expect(err).ToNot(HaveOccurred()) - phBytes, err := io.ReadAll(ph) - Expect(err).ToNot(HaveOccurred()) - - result, err := io.ReadAll(r) - Expect(err).ToNot(HaveOccurred()) - - Expect(result).To(Equal(phBytes)) - }) - }) - }) - Context("Get", func() { - Context("Empty ID", func() { - It("returns an ErrUnavailable error", func() { - _, _, err := aw.Get(context.Background(), model.ArtworkID{}, 0, false) - Expect(err).To(MatchError(artwork.ErrUnavailable)) - }) - }) - }) -}) diff --git a/core/artwork/benchmark_e2e_test.go b/core/artwork/benchmark_e2e_test.go deleted file mode 100644 index bf3d435a8..000000000 --- a/core/artwork/benchmark_e2e_test.go +++ /dev/null @@ -1,189 +0,0 @@ -package artwork - -import ( - "context" - "fmt" - "image/jpeg" - "io" - "os" - "path/filepath" - "runtime" - "sync" - "testing" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/utils/cache" -) - -// setupE2EBenchmark creates an artwork instance with a real album cover image on disk, -// backed by either a real file cache or disabled cache depending on cacheSize. -// Note: This benchmarks artwork.Get() directly (not the full HTTP handler), which covers -// the critical path (source selection, decode, resize, encode, cache). This is a deliberate -// spec deviation — the full HTTP round-trip benchmark requires significant infrastructure -// (DB, scanner, fake filesystem) and can be added later if HTTP overhead proves significant. -// -// Depends on fakeFolderRepo defined in reader_artist_test.go (same package, compiled together). -func setupE2EBenchmark(b *testing.B, cacheSize string) (Artwork, model.ArtworkID, func()) { - b.Helper() - cleanup := configtest.SetupConfig() - b.Cleanup(cleanup) - - tmpDir, err := os.MkdirTemp("", "artwork-bench-*") - if err != nil { - b.Fatal(err) - } - - // Create a realistic cover image on disk - coverPath := filepath.Join(tmpDir, "cover.jpg") - coverImg := generateGradientImage(1000, 1000) - f, err := os.Create(coverPath) - if err != nil { - b.Fatal(err) - } - if err := jpeg.Encode(f, coverImg, &jpeg.Options{Quality: 90}); err != nil { - f.Close() - b.Fatal(err) - } - f.Close() - - // Configure cache - conf.Server.ImageCacheSize = cacheSize - conf.Server.CacheFolder = conf.NewDir(tmpDir) - conf.Server.CoverArtQuality = 75 - conf.Server.CoverArtPriority = "cover.*" - - // Set up mock data store with album pointing to our cover. - // Set UpdatedAt so CoverArtID().LastUpdate is consistent across calls. - album := model.Album{ - ID: "bench-album-1", - Name: "Benchmark Album", - FolderIDs: []string{"f1"}, - UpdatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), - } - folderRepo := &fakeFolderRepo{ - result: []model.Folder{{ - Path: tmpDir, - ImageFiles: []string{"cover.jpg"}, - }}, - } - ds := &tests.MockDataStore{ - MockedTranscoding: &tests.MockTranscodingRepo{}, - MockedFolder: folderRepo, - } - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{album}) - - artID := album.CoverArtID() - - imgCache := cache.NewFileCache("BenchImage", cacheSize, "bench-images", 0, - func(ctx context.Context, arg cache.Item) (io.Reader, error) { - r, _, err := arg.(artworkReader).Reader(ctx) - return r, err - }) - - // Wait for cache init if enabled - if cacheSize != "0" { - for !imgCache.Available(context.Background()) && !imgCache.Disabled(context.Background()) { - runtime.Gosched() // Yield to allow background init goroutine to run - } - } - - ffmpeg := tests.NewMockFFmpeg("fallback content") - aw := NewArtwork(ds, imgCache, ffmpeg, nil) - - cleanupAll := func() { - os.RemoveAll(tmpDir) - } - return aw, artID, cleanupAll -} - -func BenchmarkArtworkGetE2E(b *testing.B) { - cacheConfigs := []struct { - name string - cacheSize string - }{ - {"no_cache", "0"}, - {"with_cache", "100MB"}, - } - sizes := []int{0, 300} - - for _, cc := range cacheConfigs { - for _, size := range sizes { - b.Run(fmt.Sprintf("%s/size_%d", cc.name, size), func(b *testing.B) { - aw, artID, cleanup := setupE2EBenchmark(b, cc.cacheSize) - defer cleanup() - - // Warm the cache on first call if cache is enabled - if cc.cacheSize != "0" { - r, _, err := aw.Get(context.Background(), artID, size, size > 0) - if err != nil { - b.Fatal(err) - } - _, _ = io.ReadAll(r) - r.Close() - } - - b.ResetTimer() - for i := 0; i < b.N; i++ { - r, _, err := aw.Get(context.Background(), artID, size, size > 0) - if err != nil { - b.Fatal(err) - } - _, _ = io.ReadAll(r) - r.Close() - } - }) - } - } -} - -func BenchmarkArtworkGetE2EConcurrent(b *testing.B) { - cacheConfigs := []struct { - name string - cacheSize string - }{ - {"no_cache", "0"}, - {"with_cache", "100MB"}, - } - concurrencyLevels := []int{10, 50} - - for _, cc := range cacheConfigs { - for _, n := range concurrencyLevels { - b.Run(fmt.Sprintf("%s/goroutines_%d", cc.name, n), func(b *testing.B) { - aw, artID, cleanup := setupE2EBenchmark(b, cc.cacheSize) - defer cleanup() - - // Warm cache - if cc.cacheSize != "0" { - r, _, _ := aw.Get(context.Background(), artID, 300, true) - if r != nil { - _, _ = io.ReadAll(r) - r.Close() - } - } - - b.ResetTimer() - for i := 0; i < b.N; i++ { - var wg sync.WaitGroup - wg.Add(n) - for range n { - go func() { - defer wg.Done() - r, _, err := aw.Get(context.Background(), artID, 300, true) - if err != nil { - b.Error(err) - return - } - _, _ = io.ReadAll(r) - r.Close() - }() - } - wg.Wait() - } - }) - } - } -} diff --git a/core/artwork/cache_warmer.go b/core/artwork/cache_warmer.go deleted file mode 100644 index 5090d638e..000000000 --- a/core/artwork/cache_warmer.go +++ /dev/null @@ -1,162 +0,0 @@ -package artwork - -import ( - "context" - "fmt" - "io" - "maps" - "slices" - "sync" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/utils/cache" - "github.com/navidrome/navidrome/utils/pl" -) - -type CacheWarmer interface { - PreCache(artID model.ArtworkID) -} - -// NewCacheWarmer creates a new CacheWarmer instance. The CacheWarmer will pre-cache Artwork images in the background -// to speed up the response time when the image is requested by the UI. The cache is pre-populated with the original -// image size, as well as the size defined by the UICoverArtSize config option. -func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer { - // If image cache is disabled, return a NOOP implementation - if conf.Server.ImageCacheSize == "0" || !conf.Server.EnableArtworkPrecache { - return &noopCacheWarmer{} - } - - // If the file cache is disabled, return a NOOP implementation - if cache.Disabled(context.Background()) { - log.Debug("Image cache disabled. Cache warmer will not run") - return &noopCacheWarmer{} - } - - a := &cacheWarmer{ - artwork: artwork, - cache: cache, - buffer: make(map[model.ArtworkID]struct{}), - wakeSignal: make(chan struct{}, 1), - coverArtSize: conf.Server.UICoverArtSize, - } - - // Create a context with a fake admin user, to be able to pre-cache Playlist CoverArts - ctx := request.WithUser(context.TODO(), model.User{IsAdmin: true}) - go a.run(ctx) - return a -} - -type cacheWarmer struct { - artwork Artwork - buffer map[model.ArtworkID]struct{} - mutex sync.Mutex - cache cache.FileCache - wakeSignal chan struct{} - coverArtSize int -} - -func (a *cacheWarmer) PreCache(artID model.ArtworkID) { - if a.cache.Disabled(context.Background()) { - return - } - a.mutex.Lock() - defer a.mutex.Unlock() - a.buffer[artID] = struct{}{} - a.sendWakeSignal() -} - -func (a *cacheWarmer) sendWakeSignal() { - // Don't block if the previous signal was not read yet - select { - case a.wakeSignal <- struct{}{}: - default: - } -} - -func (a *cacheWarmer) run(ctx context.Context) { - for { - a.waitSignal(ctx, 10*time.Second) - if ctx.Err() != nil { - break - } - - if a.cache.Disabled(ctx) { - a.mutex.Lock() - pending := len(a.buffer) - a.buffer = make(map[model.ArtworkID]struct{}) - a.mutex.Unlock() - if pending > 0 { - log.Trace(ctx, "Cache disabled, discarding precache buffer", "bufferLen", pending) - } - return - } - - // If cache not available, keep waiting - if !a.cache.Available(ctx) { - a.mutex.Lock() - bufferLen := len(a.buffer) - a.mutex.Unlock() - if bufferLen > 0 { - log.Trace(ctx, "Cache not available, buffering precache request", "bufferLen", bufferLen) - } - continue - } - - a.mutex.Lock() - - // If there's nothing to send, keep waiting - if len(a.buffer) == 0 { - a.mutex.Unlock() - continue - } - - batch := slices.Collect(maps.Keys(a.buffer)) - a.buffer = make(map[model.ArtworkID]struct{}) - a.mutex.Unlock() - - a.processBatch(ctx, batch) - } -} - -func (a *cacheWarmer) waitSignal(ctx context.Context, timeout time.Duration) { - select { - case <-time.After(timeout): - case <-a.wakeSignal: - case <-ctx.Done(): - } -} - -func (a *cacheWarmer) processBatch(ctx context.Context, batch []model.ArtworkID) { - log.Trace(ctx, "PreCaching a new batch of artwork", "batchSize", len(batch)) - input := pl.FromSlice(ctx, batch) - errs := pl.Sink(ctx, 4, input, a.doCacheImage) - for err := range errs { - log.Debug(ctx, "Error warming cache", err) - } -} - -func (a *cacheWarmer) doCacheImage(ctx context.Context, id model.ArtworkID) error { - ctx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - - size := a.coverArtSize - r, _, err := a.artwork.Get(ctx, id, size, true) - if err != nil { - return fmt.Errorf("caching id='%s', size=%d: %w", id, size, err) - } - _, err = io.Copy(io.Discard, r) - r.Close() - return err -} - -func NoopCacheWarmer() CacheWarmer { - return &noopCacheWarmer{} -} - -type noopCacheWarmer struct{} - -func (a *noopCacheWarmer) PreCache(model.ArtworkID) {} diff --git a/core/artwork/cache_warmer_test.go b/core/artwork/cache_warmer_test.go deleted file mode 100644 index a5da2004c..000000000 --- a/core/artwork/cache_warmer_test.go +++ /dev/null @@ -1,245 +0,0 @@ -package artwork - -import ( - "context" - "errors" - "fmt" - "io" - "strings" - "sync" - "sync/atomic" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/cache" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("CacheWarmer", func() { - var ( - fc *mockFileCache - aw *mockArtwork - ) - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - fc = &mockFileCache{} - aw = &mockArtwork{} - }) - - Context("initialization", func() { - It("returns noop when cache is disabled", func() { - fc.SetDisabled(true) - cw := NewCacheWarmer(aw, fc) - _, ok := cw.(*noopCacheWarmer) - Expect(ok).To(BeTrue()) - }) - - It("returns noop when ImageCacheSize is 0", func() { - conf.Server.ImageCacheSize = "0" - cw := NewCacheWarmer(aw, fc) - _, ok := cw.(*noopCacheWarmer) - Expect(ok).To(BeTrue()) - }) - - It("returns noop when EnableArtworkPrecache is false", func() { - conf.Server.EnableArtworkPrecache = false - cw := NewCacheWarmer(aw, fc) - _, ok := cw.(*noopCacheWarmer) - Expect(ok).To(BeTrue()) - }) - - It("returns real implementation when properly configured", func() { - conf.Server.ImageCacheSize = "100MB" - conf.Server.EnableArtworkPrecache = true - fc.SetDisabled(false) - cw := NewCacheWarmer(aw, fc) - _, ok := cw.(*cacheWarmer) - Expect(ok).To(BeTrue()) - }) - }) - - Context("buffer management", func() { - BeforeEach(func() { - conf.Server.ImageCacheSize = "100MB" - conf.Server.EnableArtworkPrecache = true - fc.SetDisabled(false) - }) - - It("drops buffered items when cache becomes disabled", func() { - cw := NewCacheWarmer(aw, fc).(*cacheWarmer) - cw.PreCache(model.MustParseArtworkID("al-test")) - fc.SetDisabled(true) - Eventually(func() int { - cw.mutex.Lock() - defer cw.mutex.Unlock() - return len(cw.buffer) - }).Should(Equal(0)) - }) - - It("adds multiple items to buffer", func() { - fc.SetReady(false) // Make cache unavailable so items stay in buffer - cw := NewCacheWarmer(aw, fc).(*cacheWarmer) - cw.PreCache(model.MustParseArtworkID("al-1")) - cw.PreCache(model.MustParseArtworkID("al-2")) - cw.mutex.Lock() - defer cw.mutex.Unlock() - Expect(len(cw.buffer)).To(Equal(2)) - }) - - It("deduplicates items in buffer", func() { - fc.SetReady(false) // Make cache unavailable so items stay in buffer - cw := NewCacheWarmer(aw, fc).(*cacheWarmer) - cw.PreCache(model.MustParseArtworkID("al-1")) - cw.PreCache(model.MustParseArtworkID("al-1")) - cw.mutex.Lock() - defer cw.mutex.Unlock() - Expect(len(cw.buffer)).To(Equal(1)) - }) - }) - - Context("error handling", func() { - BeforeEach(func() { - conf.Server.ImageCacheSize = "100MB" - conf.Server.EnableArtworkPrecache = true - fc.SetDisabled(false) - }) - - It("continues processing after artwork retrieval error", func() { - aw.err = errors.New("artwork error") - cw := NewCacheWarmer(aw, fc).(*cacheWarmer) - cw.PreCache(model.MustParseArtworkID("al-error")) - cw.PreCache(model.MustParseArtworkID("al-1")) - - Eventually(func() int { - cw.mutex.Lock() - defer cw.mutex.Unlock() - return len(cw.buffer) - }).Should(Equal(0)) - }) - - It("continues processing after cache error", func() { - fc.err = errors.New("cache error") - cw := NewCacheWarmer(aw, fc).(*cacheWarmer) - cw.PreCache(model.MustParseArtworkID("al-error")) - cw.PreCache(model.MustParseArtworkID("al-1")) - - Eventually(func() int { - cw.mutex.Lock() - defer cw.mutex.Unlock() - return len(cw.buffer) - }).Should(Equal(0)) - }) - }) - - Context("background processing", func() { - BeforeEach(func() { - conf.Server.ImageCacheSize = "100MB" - conf.Server.EnableArtworkPrecache = true - fc.SetDisabled(false) - }) - - It("processes items in batches", func() { - cw := NewCacheWarmer(aw, fc).(*cacheWarmer) - for i := range 5 { - cw.PreCache(model.MustParseArtworkID(fmt.Sprintf("al-%d", i))) - } - - Eventually(func() int { - cw.mutex.Lock() - defer cw.mutex.Unlock() - return len(cw.buffer) - }).Should(Equal(0)) - }) - - It("wakes up on new items", func() { - cw := NewCacheWarmer(aw, fc).(*cacheWarmer) - - // Add first batch - cw.PreCache(model.MustParseArtworkID("al-1")) - Eventually(func() int { - cw.mutex.Lock() - defer cw.mutex.Unlock() - return len(cw.buffer) - }).Should(Equal(0)) - - // Add second batch - cw.PreCache(model.MustParseArtworkID("al-2")) - Eventually(func() int { - cw.mutex.Lock() - defer cw.mutex.Unlock() - return len(cw.buffer) - }).Should(Equal(0)) - }) - - It("pre-caches UICoverArtSize", func() { - cw := NewCacheWarmer(aw, fc).(*cacheWarmer) - cw.PreCache(model.MustParseArtworkID("al-1")) - - Eventually(func() []int { - return aw.getCachedSizes() - }).Should(ContainElements(conf.Server.UICoverArtSize)) - }) - }) -}) - -type mockArtwork struct { - err error - mu sync.Mutex - cachedSizes []int -} - -func (m *mockArtwork) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (io.ReadCloser, time.Time, error) { - if m.err != nil { - return nil, time.Time{}, m.err - } - m.mu.Lock() - m.cachedSizes = append(m.cachedSizes, size) - m.mu.Unlock() - return io.NopCloser(strings.NewReader("test")), time.Now(), nil -} - -func (m *mockArtwork) getCachedSizes() []int { - m.mu.Lock() - defer m.mu.Unlock() - result := make([]int, len(m.cachedSizes)) - copy(result, m.cachedSizes) - return result -} - -func (m *mockArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) { - return m.Get(ctx, model.ArtworkID{}, size, square) -} - -type mockFileCache struct { - disabled atomic.Bool - ready atomic.Bool - err error -} - -func (f *mockFileCache) Get(ctx context.Context, item cache.Item) (*cache.CachedStream, error) { - if f.err != nil { - return nil, f.err - } - return &cache.CachedStream{Reader: io.NopCloser(strings.NewReader("cached"))}, nil -} - -func (f *mockFileCache) Available(ctx context.Context) bool { - return f.ready.Load() && !f.disabled.Load() -} - -func (f *mockFileCache) Disabled(ctx context.Context) bool { - return f.disabled.Load() -} - -func (f *mockFileCache) SetDisabled(v bool) { - f.disabled.Store(v) - f.ready.Store(true) -} - -func (f *mockFileCache) SetReady(v bool) { - f.ready.Store(v) -} diff --git a/core/artwork/reader_disc.go b/core/artwork/disc.go similarity index 79% rename from core/artwork/reader_disc.go rename to core/artwork/disc.go index 0f648c987..8bc566af2 100644 --- a/core/artwork/reader_disc.go +++ b/core/artwork/disc.go @@ -2,26 +2,22 @@ package artwork import ( "context" - "crypto/md5" "fmt" "io" "path" "path/filepath" "strconv" "strings" - "time" "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils" ) +// discArtworkReader resolves disc-level artwork from a library's folder images +// and embedded tags. It is used by the serving path's provisional disc read-through. type discArtworkReader struct { - cacheKey - a *artwork album model.Album discNumber int imgFiles []string // library-relative, forward-slash, no leading slash @@ -29,27 +25,26 @@ type discArtworkReader struct { isMultiFolder bool firstTrackRel string // library-relative; for fromTag / ffmpeg via lib.Abs lib libraryView - updatedAt *time.Time } -func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID) (*discArtworkReader, error) { +func newDiscArtworkReader(ctx context.Context, ds model.DataStore, artID model.ArtworkID) (*discArtworkReader, error) { albumID, discNumber, err := model.ParseDiscArtworkID(artID.ID) if err != nil { return nil, fmt.Errorf("invalid disc artwork id '%s': %w", artID.ID, err) } - al, err := a.ds.Album(ctx).Get(albumID) + al, err := ds.Album(ctx).Get(albumID) if err != nil { return nil, err } - _, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, a.ds, *al) + _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, *al) if err != nil { return nil, err } // Query mediafiles for this album + disc to find folder associations and first track - mfs, err := a.ds.MediaFile(ctx).GetAll(model.QueryOptions{ + mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ Sort: "track_number", Order: "ASC", Filters: squirrel.Eq{"album_id": albumID, "disc_number": discNumber}, @@ -58,7 +53,7 @@ func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID return nil, err } - lib, err := loadLibraryView(ctx, a.ds, al.LibraryID) + lib, err := loadLibraryView(ctx, ds, al.LibraryID) if err != nil { return nil, err } @@ -80,7 +75,7 @@ func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID for id := range allFolderIDs { folderIDs = append(folderIDs, id) } - folders, err := a.ds.Folder(ctx).GetAll(model.QueryOptions{ + folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"folder.id": folderIDs}, }) if err != nil { @@ -92,46 +87,15 @@ func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID } } - isMultiFolder := len(al.FolderIDs) > 1 - - r := &discArtworkReader{ - a: a, + return &discArtworkReader{ album: *al, discNumber: discNumber, imgFiles: imgFiles, discFoldersRel: discFoldersRel, - isMultiFolder: isMultiFolder, + isMultiFolder: len(al.FolderIDs) > 1, firstTrackRel: firstTrackRel, lib: lib, - updatedAt: imagesUpdatedAt, - } - r.cacheKey.artID = artID - r.cacheKey.lastUpdate = utils.TimeNewest(al.UpdatedAt, al.ImportedAt) - if imagesUpdatedAt != nil { - r.cacheKey.lastUpdate = utils.TimeNewest(r.cacheKey.lastUpdate, *imagesUpdatedAt) - } - return r, nil -} - -func (d *discArtworkReader) Key() string { - hash := md5.Sum([]byte(conf.Server.DiscArtPriority)) - return fmt.Sprintf( - "%s.%x", - d.cacheKey.Key(), - hash, - ) -} - -func (d *discArtworkReader) LastUpdated() time.Time { - return d.lastUpdate -} - -func (d *discArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { - var ff = d.fromDiscArtPriority(ctx, d.a.ffmpeg, conf.Server.DiscArtPriority) - // Fallback to album cover art - albumArtID := model.NewArtworkID(model.KindAlbumArtwork, d.album.ID, &d.album.UpdatedAt) - ff = append(ff, fromAlbum(ctx, d.a, albumArtID)) - return selectImageReader(ctx, d.cacheKey.artID, ff...) + }, nil } func (d *discArtworkReader) fromDiscArtPriority(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string) []sourceFunc { diff --git a/core/artwork/reader_disc_test.go b/core/artwork/disc_test.go similarity index 100% rename from core/artwork/reader_disc_test.go rename to core/artwork/disc_test.go diff --git a/core/artwork/e2e/album_test.go b/core/artwork/e2e/album_test.go deleted file mode 100644 index 5e61684cc..000000000 --- a/core/artwork/e2e/album_test.go +++ /dev/null @@ -1,469 +0,0 @@ -package artworke2e_test - -import ( - "testing/fstest" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -const ( - defaultCoverPriority = "cover.*, folder.*, front.*, embedded, external" - defaultDiscPriority = "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded" -) - -var _ = Describe("Album artwork resolution", func() { - BeforeEach(func() { - setupHarness() - }) - - When("an album has a single folder with cover.jpg at the album root", func() { - // Artist/ - // └── Album/ - // ├── 01 - Track.mp3 - // └── cover.jpg ← matched by cover.* - It("returns the album-root cover", func() { - conf.Server.CoverArtPriority = defaultCoverPriority - setLayout(fstest.MapFS{ - "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/cover.jpg": imageFile("album-root"), - }) - scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root"))) - }) - }) - - // https://github.com/navidrome/navidrome/issues/5376 - // cover.* basenames tie across album-root and per-disc folders; - // compareImageFiles must prefer shallower paths. - When("a multi-disc album has a cover.jpg at the album root and per-disc covers", func() { - // Artist/ - // └── Album/ - // ├── CD1/ - // │ ├── 01 - Track.mp3 - // │ └── cover.jpg ← should not win - // ├── CD2/ - // │ ├── 01 - Track.mp3 - // │ └── cover.jpg - // └── cover.jpg ← should win (album-root fallback) - It("prefers the album-root cover over per-disc covers", func() { - conf.Server.CoverArtPriority = defaultCoverPriority - setLayout(fstest.MapFS{ - "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"), - "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"), - "Artist/Album/cover.jpg": imageFile("album-root"), - "Artist/Album/CD1/cover.jpg": imageFile("disc1"), - "Artist/Album/CD2/cover.jpg": imageFile("disc2"), - }) - scan() - - al := firstAlbum() - Expect(al.FolderIDs).To(HaveLen(2), - "sanity check: scanner should treat the two disc subfolders as one multi-disc album") - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root"))) - }) - }) - - // https://github.com/navidrome/navidrome/issues/5376 - // folder.jpg basenames tie across album-root and per-disc folders; - // compareImageFiles must prefer shallower paths. - When("a multi-disc album has folder.jpg at the album root AND in each disc subfolder", func() { - // Artist/ - // └── Album/ - // ├── CD1/ - // │ ├── 01 - Track.mp3 - // │ └── folder.jpg ← should not win - // ├── CD2/ - // │ ├── 01 - Track.mp3 - // │ └── folder.jpg - // └── folder.jpg ← should win (album-root fallback) - It("prefers the album-root folder.jpg over per-disc folder.jpg", func() { - conf.Server.CoverArtPriority = defaultCoverPriority - setLayout(fstest.MapFS{ - "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"), - "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"), - "Artist/Album/folder.jpg": imageFile("album-root"), - "Artist/Album/CD1/folder.jpg": imageFile("disc1"), - "Artist/Album/CD2/folder.jpg": imageFile("disc2"), - }) - scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root"))) - }) - }) - - // https://github.com/navidrome/navidrome/issues/5376 - // Single-subfolder albums must still consider the parent folder's images. - When("an album lives entirely under a single disc subfolder with cover.jpg at the parent", func() { - // Artist/ - // └── Album/ - // ├── disc1/ - // │ └── 01 - Track.mp3 - // └── cover.jpg ← should win (parent-folder fallback) - It("uses the parent-folder cover for single-disc-subfolder albums", func() { - conf.Server.CoverArtPriority = defaultCoverPriority - setLayout(fstest.MapFS{ - "Artist/Album/disc1/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/cover.jpg": imageFile("album-root"), - }) - scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root"))) - }) - }) - - // https://github.com/navidrome/navidrome/issues/5456 - When("a top-level multi-disc album has cover.jpg at the album root and per-disc folder.jpg", func() { - // Album/ (top-level folder, Path=".") - // ├── CD1/ - // │ ├── 01 - Track.mp3 - // │ └── folder.jpg - // ├── CD2/ - // │ ├── 01 - Track.mp3 - // │ └── folder.jpg - // └── cover.jpg ← should win (album-root) - It("prefers the album-root cover.jpg", func() { - conf.Server.CoverArtPriority = defaultCoverPriority - setLayout(fstest.MapFS{ - "Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"), - "Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"), - "Album/cover.jpg": imageFile("album-root"), - "Album/CD1/folder.jpg": imageFile("disc1"), - "Album/CD2/folder.jpg": imageFile("disc2"), - }) - scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root"))) - }) - }) - - When("CoverArtPriority puts embedded first and the album has both embedded and external art", func() { - // Artist/ - // └── Album/ - // ├── 01 - Track.mp3 ← has embedded picture (wins via "embedded") - // └── cover.jpg - It("returns the embedded image", func() { - conf.Server.CoverArtPriority = "embedded, cover.*, folder.*, front.*, external" - setLayout(fstest.MapFS{ - "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}), - "Artist/Album/cover.jpg": imageFile("external"), - }) - scan() - // Swap in real MP3 bytes so libFS.Open returns a taglib-readable stream. - replaceWithRealMP3("Artist/Album/01 - Track.mp3") - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(embeddedArtBytes)) - }) - }) - - When("CoverArtPriority lists external first but no external file is present", func() { - // Artist/ - // └── Album/ - // └── 01 - Track.mp3 ← has embedded picture (falls through to "embedded") - It("falls through to embedded artwork", func() { - conf.Server.CoverArtPriority = "external, embedded" - setLayout(fstest.MapFS{ - "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}), - }) - scan() - replaceWithRealMP3("Artist/Album/01 - Track.mp3") - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(embeddedArtBytes)) - }) - }) - - When("the only cover file uses uppercase extension and a different case in its name", func() { - // Artist/ - // └── Album/ - // ├── 01 - Track.mp3 - // └── Cover.JPG ← matched case-insensitively by cover.* - It("matches case-insensitively against cover.*", func() { - conf.Server.CoverArtPriority = "cover.*, folder.*" - setLayout(fstest.MapFS{ - "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/Cover.JPG": imageFile("case-insensitive"), - }) - scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("case-insensitive"))) - }) - }) - - When("two cover files have basenames that tie under the natural-sort tiebreaker", func() { - // Artist/ - // └── Album/ - // ├── 01 - Track.mp3 - // ├── cover.jpg ← wins (no numeric suffix) - // └── cover.1.jpg - It("prefers the file without a numeric suffix", func() { - conf.Server.CoverArtPriority = "cover.*" - setLayout(fstest.MapFS{ - "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/cover.jpg": imageFile("primary"), - "Artist/Album/cover.1.jpg": imageFile("secondary"), - }) - scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("primary"))) - }) - }) - - When("the album has no cover and CoverArtPriority lists only file patterns", func() { - // Artist/ - // └── Album/ - // └── 01 - Track.mp3 (no image files — returns ErrUnavailable) - It("returns ErrUnavailable", func() { - conf.Server.CoverArtPriority = "cover.*, folder.*" - setLayout(fstest.MapFS{ - "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - }) - scan() - - al := firstAlbum() - _, err := readArtworkOrErr(model.NewArtworkID(model.KindAlbumArtwork, al.ID, &al.UpdatedAt)) - Expect(err).To(HaveOccurred()) - }) - }) - - // Doc scenarios from: - // https://www.navidrome.org/docs/usage/library/artwork/#albums - // Default CoverArtPriority is "cover.*, folder.*, front.*, embedded, external". - When("only folder.jpg is present (cover.* and front.* missing)", func() { - // Artist/ - // └── Album/ - // ├── 01 - Track.mp3 - // └── folder.jpg ← matched by folder.* - It("falls through to folder.jpg", func() { - conf.Server.CoverArtPriority = defaultCoverPriority - setLayout(fstest.MapFS{ - "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/folder.jpg": imageFile("folder"), - }) - scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("folder"))) - }) - }) - - When("only front.jpg is present (cover.* and folder.* missing)", func() { - // Artist/ - // └── Album/ - // ├── 01 - Track.mp3 - // └── front.jpg ← matched by front.* - It("falls through to front.jpg", func() { - conf.Server.CoverArtPriority = defaultCoverPriority - setLayout(fstest.MapFS{ - "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/front.jpg": imageFile("front"), - }) - scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("front"))) - }) - }) - - When("cover.*, folder.*, and front.* all exist in the same folder", func() { - // Artist/ - // └── Album/ - // ├── 01 - Track.mp3 - // ├── cover.jpg ← wins (cover.* is first in priority) - // ├── folder.jpg - // └── front.jpg - It("prefers cover.* (first in CoverArtPriority)", func() { - conf.Server.CoverArtPriority = defaultCoverPriority - setLayout(fstest.MapFS{ - "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/cover.jpg": imageFile("cover"), - "Artist/Album/folder.jpg": imageFile("folder"), - "Artist/Album/front.jpg": imageFile("front"), - }) - scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover"))) - }) - }) - - When("only folder.* and front.* exist (priority order check)", func() { - // Artist/ - // └── Album/ - // ├── 01 - Track.mp3 - // ├── folder.jpg ← wins (folder.* comes before front.*) - // └── front.jpg - It("prefers folder.* over front.*", func() { - conf.Server.CoverArtPriority = defaultCoverPriority - setLayout(fstest.MapFS{ - "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/folder.jpg": imageFile("folder"), - "Artist/Album/front.jpg": imageFile("front"), - }) - scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("folder"))) - }) - }) - - When("three cover files tie by basename and differ only by numeric suffix", func() { - // Artist/ - // └── Album/ - // ├── 01 - Track.mp3 - // ├── cover.jpg ← wins (no numeric suffix) - // ├── cover.1.jpg - // └── cover.2.jpg - It("selects the unsuffixed file first regardless of numeric-suffix order", func() { - conf.Server.CoverArtPriority = "cover.*" - setLayout(fstest.MapFS{ - "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/cover.2.jpg": imageFile("second"), - "Artist/Album/cover.jpg": imageFile("primary"), - "Artist/Album/cover.1.jpg": imageFile("first"), - }) - scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("primary"))) - }) - }) - - When("CoverArtPriority contains an unknown pattern before a matching one", func() { - // Artist/ - // └── Album/ - // ├── 01 - Track.mp3 - // └── cover.jpg ← wins (unknown "bogus.*" is skipped) - It("skips the unknown pattern and falls through to the matching one", func() { - conf.Server.CoverArtPriority = "bogus.*, cover.*" - setLayout(fstest.MapFS{ - "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/cover.jpg": imageFile("cover"), - }) - scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover"))) - }) - }) - - // Regression introduced in v0.62.0 (#5451 + #5457): the parent-folder - // fallback can pick up images from the ARTIST folder, serving the artist - // thumbnail as album art for any album without its own image files. - When("an album has no images and the artist folder has folder.jpg", func() { - // Artist/ - // ├── folder.jpg ← artist thumbnail, must NOT become album art - // ├── Album A/ - // │ └── 01 - Track.mp3 (no images) - // └── Album B/ - // ├── 01 - Track.mp3 - // └── cover.jpg - It("does not use the artist image as album art", func() { - conf.Server.CoverArtPriority = defaultCoverPriority - setLayout(fstest.MapFS{ - "Artist/folder.jpg": imageFile("artist-thumbnail"), - "Artist/Album A/01 - Track.mp3": trackFile(1, "Track A", map[string]any{"album": "Album A", "albumartist": "Artist"}), - "Artist/Album B/01 - Track.mp3": trackFile(1, "Track B", map[string]any{"album": "Album B", "albumartist": "Artist"}), - "Artist/Album B/cover.jpg": imageFile("album-b"), - }) - scan() - - alA := albumByName("Album A") - _, err := readArtworkOrErr(alA.CoverArtID()) - Expect(err).To(HaveOccurred(), - "Album A has no images of its own, so it must fall through to the placeholder "+ - "instead of inheriting the artist folder's folder.jpg") - - alB := albumByName("Album B") - Expect(readArtwork(alB.CoverArtID())).To(Equal(imageBytes("album-b"))) - }) - }) - - When("a single-disc album is spread across sibling folders under the artist folder", func() { - // Artist/ - // ├── folder.jpg ← artist thumbnail, must NOT become album art - // ├── Album A/ - // │ └── 01 - Track.mp3 (album: "Album A") - // ├── Album A bonus/ - // │ └── 02 - Track.mp3 (album: "Album A" — same album, second folder) - // └── Album B/ - // ├── 01 - Track.mp3 - // └── cover.jpg - It("does not use the artist image as album art for the spread album", func() { - conf.Server.CoverArtPriority = defaultCoverPriority - setLayout(fstest.MapFS{ - "Artist/folder.jpg": imageFile("artist-thumbnail"), - "Artist/Album A/01 - Track.mp3": trackFile(1, "Track A1", map[string]any{"album": "Album A", "albumartist": "Artist"}), - "Artist/Album A bonus/02 - Track.mp3": trackFile(2, "Track A2", map[string]any{"album": "Album A", "albumartist": "Artist"}), - "Artist/Album B/01 - Track.mp3": trackFile(1, "Track B", map[string]any{"album": "Album B", "albumartist": "Artist"}), - "Artist/Album B/cover.jpg": imageFile("album-b"), - }) - scan() - - alA := albumByName("Album A") - Expect(alA.FolderIDs).To(HaveLen(2), - "sanity check: scanner should treat the two sibling folders as one spread album") - _, err := readArtworkOrErr(alA.CoverArtID()) - Expect(err).To(HaveOccurred(), - "the spread album has no images of its own, so it must fall through to the "+ - "placeholder instead of inheriting the artist folder's folder.jpg") - }) - }) - - When("a spread album has its own front.jpg but the artist folder has cover.jpg", func() { - // Artist/ - // ├── cover.jpg ← artist image; matches cover.* (first pattern), - // │ must NOT shadow the album's own front.jpg - // ├── Album A/ - // │ ├── 01 - Track.mp3 (album: "Album A") - // │ └── front.jpg ← should win - // ├── Album A bonus/ - // │ └── 02 - Track.mp3 (album: "Album A") - // └── Album B/ - // └── 01 - Track.mp3 - It("prefers the album's own art over the artist image", func() { - conf.Server.CoverArtPriority = defaultCoverPriority - setLayout(fstest.MapFS{ - "Artist/cover.jpg": imageFile("artist-image"), - "Artist/Album A/01 - Track.mp3": trackFile(1, "Track A1", map[string]any{"album": "Album A", "albumartist": "Artist"}), - "Artist/Album A/front.jpg": imageFile("album-a-front"), - "Artist/Album A bonus/02 - Track.mp3": trackFile(2, "Track A2", map[string]any{"album": "Album A", "albumartist": "Artist"}), - "Artist/Album B/01 - Track.mp3": trackFile(1, "Track B", map[string]any{"album": "Album B", "albumartist": "Artist"}), - }) - scan() - - alA := albumByName("Album A") - Expect(alA.FolderIDs).To(HaveLen(2), - "sanity check: scanner should treat the two sibling folders as one spread album") - Expect(readArtwork(alA.CoverArtID())).To(Equal(imageBytes("album-a-front"))) - }) - }) - - When("embedded is first in CoverArtPriority but the track has no embedded art", func() { - // Artist/ - // └── Album/ - // ├── 01 - Track.mp3 (no embedded picture) - // └── cover.jpg ← wins (embedded skipped, falls through) - It("falls through to the next priority entry", func() { - conf.Server.CoverArtPriority = "embedded, cover.*" - setLayout(fstest.MapFS{ - "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/cover.jpg": imageFile("cover"), - }) - scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover"))) - }) - }) -}) diff --git a/core/artwork/e2e/artist_test.go b/core/artwork/e2e/artist_test.go deleted file mode 100644 index d959b1d60..000000000 --- a/core/artwork/e2e/artist_test.go +++ /dev/null @@ -1,167 +0,0 @@ -package artworke2e_test - -import ( - "os" - "path/filepath" - "testing/fstest" - - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// Doc reference: -// https://www.navidrome.org/docs/usage/library/artwork/#artists -// Default ArtistArtPriority is "artist.*, album/artist.*, external". -var _ = Describe("Artist artwork resolution", func() { - BeforeEach(func() { - setupHarness() - }) - - When("the artist folder contains an artist.jpg", func() { - // Artist/ - // ├── artist.jpg ← matched by artist.* - // └── Album/ - // └── 01 - Track.mp3 - It("returns the artist.* image from the artist folder", func() { - conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" - setLayout(fstest.MapFS{ - "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), - "Artist/artist.jpg": imageFile("artist-folder"), - }) - scan() - - ar := soleArtist() - artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) - Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder"))) - }) - }) - - When("artist.* only exists inside an album folder", func() { - // Artist/ - // └── Album/ - // ├── 01 - Track.mp3 - // └── artist.jpg ← matched by album/artist.* - It("falls through to album/artist.* and returns that image", func() { - conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" - setLayout(fstest.MapFS{ - "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), - "Artist/Album/artist.jpg": imageFile("album-artist"), - }) - scan() - - ar := soleArtist() - artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) - Expect(readArtwork(artID)).To(Equal(imageBytes("album-artist"))) - }) - }) - - When("both the artist folder and an album folder have an artist.* image", func() { - // Artist/ - // ├── artist.jpg ← wins (artist.* before album/artist.*) - // └── Album/ - // ├── 01 - Track.mp3 - // └── artist.jpg - It("prefers the artist-folder image (artist.* comes before album/artist.*)", func() { - conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" - setLayout(fstest.MapFS{ - "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), - "Artist/artist.jpg": imageFile("artist-folder"), - "Artist/Album/artist.jpg": imageFile("album-artist"), - }) - scan() - - ar := soleArtist() - artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) - Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder"))) - }) - }) - - When("an artist has an uploaded image and a matching artist.* file", func() { - // / - // └── artwork/ - // └── artist/ - // └── _upload.jpg ← wins (uploaded image beats the priority chain) - // Library: - // Artist/ - // ├── artist.jpg (ignored — uploaded image comes first) - // └── Album/ - // └── 01 - Track.mp3 - It("prefers the uploaded image over any priority-chain match", func() { - conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" - setLayout(fstest.MapFS{ - "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), - "Artist/artist.jpg": imageFile("artist-folder"), - }) - scan() - ar := soleArtist() - - uploaded := ar.ID + "_upload.jpg" - writeUploadedImage(consts.EntityArtist, uploaded, imageBytes("artist-uploaded")) - ar.UploadedImage = uploaded - Expect(ds.Artist(ctx).Put(&ar)).To(Succeed()) - - artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) - Expect(readArtwork(artID)).To(Equal(imageBytes("artist-uploaded"))) - }) - }) - - When("ArtistArtPriority uses album/ (not just album/artist.*)", func() { - // Artist/ - // └── Album/ - // ├── 01 - Track.mp3 - // └── artist.jpg ← matched by album/artist.* - It("resolves the pattern against the artist's album image files", func() { - conf.Server.ArtistArtPriority = "album/artist.*, external" - setLayout(fstest.MapFS{ - "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), - "Artist/Album/artist.jpg": imageFile("album-artist"), - }) - scan() - - ar := soleArtist() - artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) - Expect(readArtwork(artID)).To(Equal(imageBytes("album-artist"))) - }) - }) - - When("ArtistArtPriority starts with image-folder and ArtistImageFolder has a name-matching image", func() { - // / - // └── Artist.jpg ← matched by artist name (image-folder source) - // Library: - // Artist/ - // └── Album/ - // └── 01 - Track.mp3 (no artist.* present in library) - It("returns the image from the configured artist image folder", func() { - imgFolder := GinkgoT().TempDir() - Expect(os.WriteFile(filepath.Join(imgFolder, "Artist.jpg"), imageBytes("image-folder"), 0600)).To(Succeed()) - conf.Server.ArtistImageFolder = imgFolder - conf.Server.ArtistArtPriority = "image-folder, artist.*, album/artist.*" - - setLayout(fstest.MapFS{ - "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), - }) - scan() - - ar := soleArtist() - artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) - Expect(readArtwork(artID)).To(Equal(imageBytes("image-folder"))) - }) - }) -}) - -func soleArtist() model.Artist { - GinkgoHelper() - artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ - Filters: squirrel.Eq{"artist.name": "Artist"}, - }) - Expect(err).ToNot(HaveOccurred()) - if len(artists) == 0 { - Fail("sole artist not found") - return model.Artist{} - } - return artists[0] -} diff --git a/core/artwork/e2e/disc_test.go b/core/artwork/e2e/disc_test.go deleted file mode 100644 index 667079458..000000000 --- a/core/artwork/e2e/disc_test.go +++ /dev/null @@ -1,371 +0,0 @@ -package artworke2e_test - -import ( - "fmt" - "testing/fstest" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Disc artwork resolution", func() { - BeforeEach(func() { - setupHarness() - }) - - When("the album is single-disc with a disc1.jpg in the only folder", func() { - // Artist/ - // └── Album/ - // ├── 01 - Track.mp3 - // └── disc1.jpg ← matched by disc*.* - It("returns the disc1.jpg image (matched as disc*.*)", func() { - conf.Server.DiscArtPriority = "disc*.*, cd*.*, cover.*, folder.*, front.*, embedded" - setLayout(fstest.MapFS{ - "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/disc1.jpg": imageFile("disc1-image"), - }) - scan() - - al := firstAlbum() - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) - Expect(readArtwork(discID)).To(Equal(imageBytes("disc1-image"))) - }) - }) - - When("the album has no per-disc image and no album cover", func() { - // Artist/ - // └── Album/ - // └── 01 - Track.mp3 (no disc or album art — returns ErrUnavailable) - It("returns ErrUnavailable for the disc lookup", func() { - conf.Server.DiscArtPriority = "disc*.*, cd*.*" - conf.Server.CoverArtPriority = "cover.*, folder.*" - setLayout(fstest.MapFS{ - "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - }) - scan() - - al := firstAlbum() - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) - _, err := readArtworkOrErr(discID) - Expect(err).To(HaveOccurred()) - }) - }) - - When("the album has no per-disc image but has an album cover", func() { - // Artist/ - // └── Album/ - // ├── 01 - Track.mp3 - // └── cover.jpg ← album-level fallback (no disc art present) - It("falls back to the album cover", func() { - conf.Server.DiscArtPriority = "disc*.*, cd*.*" - conf.Server.CoverArtPriority = defaultCoverPriority - setLayout(fstest.MapFS{ - "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/cover.jpg": imageFile("album-cover"), - }) - scan() - - al := firstAlbum() - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) - Expect(readArtwork(discID)).To(Equal(imageBytes("album-cover"))) - }) - }) - - When("multiple disc images exist in the same folder (disc1 vs disc10)", func() { - // Artist/ - // └── Album/ - // ├── 01 - Track.mp3 - // ├── disc1.jpg ← matches request for disc 1 - // └── disc10.jpg - It("matches the requested disc number, not a higher-numbered one", func() { - conf.Server.DiscArtPriority = "disc*.*" - setLayout(fstest.MapFS{ - "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/disc1.jpg": imageFile("disc-one"), - "Artist/Album/disc10.jpg": imageFile("disc-ten"), - }) - scan() - - al := firstAlbum() - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) - Expect(readArtwork(discID)).To(Equal(imageBytes("disc-one"))) - }) - }) - - When("a multi-disc album has per-disc covers", func() { - // Artist/ - // └── Album/ - // ├── CD1/ - // │ ├── 01 - Track.mp3 - // │ └── disc1.jpg ← matches request for disc 1 - // └── CD2/ - // ├── 01 - Track.mp3 - // └── disc2.jpg ← matches request for disc 2 - It("returns the requested disc's image", func() { - conf.Server.DiscArtPriority = "disc*.*" - setLayout(fstest.MapFS{ - "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), - "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), - "Artist/Album/CD1/disc1.jpg": imageFile("disc-1"), - "Artist/Album/CD2/disc2.jpg": imageFile("disc-2"), - }) - scan() - - al := firstAlbum() - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt) - Expect(readArtwork(discID)).To(Equal(imageBytes("disc-2"))) - }) - }) - - // Doc scenarios from: - // https://www.navidrome.org/docs/usage/library/artwork/#disc-cover-art - // Default DiscArtPriority is "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded". - When("a disc subfolder has a cd2.png image", func() { - // Artist/ - // └── Album/ - // ├── CD1/ - // │ ├── 01 - Track.mp3 - // │ └── disc1.jpg - // └── CD2/ - // ├── 01 - Track.mp3 - // └── cd2.png ← matched by cd*.* for disc 2 - It("matches via the cd*.* pattern", func() { - conf.Server.DiscArtPriority = defaultDiscPriority - setLayout(fstest.MapFS{ - "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), - "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), - "Artist/Album/CD1/disc1.jpg": imageFile("disc-1"), - "Artist/Album/CD2/cd2.png": imageFile("cd-2"), - }) - scan() - - al := firstAlbum() - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt) - Expect(readArtwork(discID)).To(Equal(imageBytes("cd-2"))) - }) - }) - - When("a disc subfolder has cover.jpg but no disc*.*/cd*.* image", func() { - // Artist/ - // └── Album/ - // ├── CD1/ - // │ ├── 01 - Track.mp3 - // │ └── cover.jpg ← matched by cover.* inside disc folder - // └── CD2/ - // ├── 01 - Track.mp3 - // └── cover.jpg - It("falls through to cover.* inside the disc folder", func() { - conf.Server.DiscArtPriority = defaultDiscPriority - setLayout(fstest.MapFS{ - "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), - "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), - "Artist/Album/CD1/cover.jpg": imageFile("disc1-cover"), - "Artist/Album/CD2/cover.jpg": imageFile("disc2-cover"), - }) - scan() - - al := firstAlbum() - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) - Expect(readArtwork(discID)).To(Equal(imageBytes("disc1-cover"))) - }) - }) - - When("DiscArtPriority is the empty string", func() { - // Artist/ - // └── Album/ - // ├── CD1/ - // │ ├── 01 - Track.mp3 - // │ └── disc1.jpg (ignored — DiscArtPriority is empty) - // ├── CD2/ - // │ ├── 01 - Track.mp3 - // │ └── cd2.png (ignored — DiscArtPriority is empty) - // └── cover.jpg ← used for every disc (album-level fallback) - It("skips every disc-level source and returns the album cover", func() { - conf.Server.DiscArtPriority = "" - conf.Server.CoverArtPriority = defaultCoverPriority - setLayout(fstest.MapFS{ - "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), - "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), - "Artist/Album/CD1/disc1.jpg": imageFile("disc-1"), - "Artist/Album/CD2/cd2.png": imageFile("cd-2"), - "Artist/Album/cover.jpg": imageFile("album-cover"), - }) - scan() - - al := firstAlbum() - for _, n := range []int{1, 2} { - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, n), &al.UpdatedAt) - Expect(readArtwork(discID)).To(Equal(imageBytes("album-cover")), - "disc %d should use the album cover when DiscArtPriority is empty", n) - } - }) - }) - - When("the documented multi-disc layout is used (disc1.jpg + cd2.png + album-root cover.jpg)", func() { - // Artist/ - // └── Album/ - // ├── disc1/ - // │ ├── disc1.jpg ← matched by disc*.* for disc 1 - // │ ├── 01 - Track.mp3 - // │ └── 02 - Track.mp3 - // ├── disc2/ - // │ ├── cd2.png ← matched by cd*.* for disc 2 - // │ ├── 01 - Track.mp3 - // │ └── 02 - Track.mp3 - // └── cover.jpg (album-level fallback, unused here) - It("matches the per-disc image for each disc", func() { - conf.Server.DiscArtPriority = defaultDiscPriority - conf.Server.CoverArtPriority = defaultCoverPriority - setLayout(fstest.MapFS{ - "Artist/Album/disc1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), - "Artist/Album/disc1/02 - Track.mp3": trackFile(2, "T2", map[string]any{"disc": "1"}), - "Artist/Album/disc2/01 - Track.mp3": trackFile(1, "T3", map[string]any{"disc": "2"}), - "Artist/Album/disc2/02 - Track.mp3": trackFile(2, "T4", map[string]any{"disc": "2"}), - "Artist/Album/disc1/disc1.jpg": imageFile("disc-1"), - "Artist/Album/disc2/cd2.png": imageFile("cd-2"), - "Artist/Album/cover.jpg": imageFile("album-root"), - }) - scan() - - al := firstAlbum() - disc1ID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) - disc2ID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt) - Expect(readArtwork(disc1ID)).To(Equal(imageBytes("disc-1"))) - Expect(readArtwork(disc2ID)).To(Equal(imageBytes("cd-2"))) - }) - }) - - When("discsubtitle keyword matches an image whose stem equals the disc's subtitle", func() { - // Artist/ - // └── Album/ - // ├── 01 - Track.mp3 (discsubtitle="Bonus Tracks") - // └── Bonus Tracks.jpg ← matched by "discsubtitle" keyword - It("selects the subtitle-named image", func() { - conf.Server.DiscArtPriority = "discsubtitle" - setLayout(fstest.MapFS{ - "Artist/Album/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1", "discsubtitle": "Bonus Tracks"}), - "Artist/Album/Bonus Tracks.jpg": imageFile("bonus-tracks"), - }) - scan() - - al := firstAlbum() - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) - Expect(readArtwork(discID)).To(Equal(imageBytes("bonus-tracks"))) - }) - }) - - // Reproduces https://github.com/navidrome/navidrome/issues/5456 - // Deeply nested layout matching the reporter's actual structure. - When("a deeply nested multi-disc album has cover.jpg and per-disc folder.jpg", func() { - // Genre/Artist/Album/ ← album root with cover.jpg - // ├── cover.jpg ← album-level cover - // ├── Disc 01 (Subtitle)/ - // │ ├── 01 - Track.mp3 - // │ └── folder.jpg ← disc 1 art - // ├── Disc 02 (Subtitle)/ - // │ ├── 01 - Track.mp3 - // │ └── folder.jpg - // └── ... (12 discs) - It("uses album-root cover.jpg for album art and per-disc folder.jpg for each disc", func() { - conf.Server.DiscArtPriority = defaultDiscPriority - conf.Server.CoverArtPriority = defaultCoverPriority - discNames := []string{ - "Disc 01 (Birth of the Dead - The Studio Sides)", - "Disc 02 (Birth of the Dead - The Live Sides)", - "Disc 03 (The Grateful Dead)", - "Disc 04 (Anthem of the Sun)", - "Disc 05 (Aoxomoxoa)", - "Disc 06 (Live; Dead)", - "Disc 07 (Workingman's Dead)", - "Disc 08 (American Beauty)", - "Disc 09 (Grateful Dead)", - "Disc 10 (Europe '72)", - "Disc 11 (Europe '72)", - "Disc 12 (History of the Grateful Dead, Volume One (Bear's Choice))", - } - layout := fstest.MapFS{ - "Pop; Rock/Grateful Dead/(2001) The Golden Road/cover.jpg": imageFile("album-root-cover"), - } - for i, name := range discNames { - discNum := i + 1 - prefix := fmt.Sprintf("Pop; Rock/Grateful Dead/(2001) The Golden Road/%s/", name) - layout[prefix+"01 - Track.mp3"] = trackFile(1, fmt.Sprintf("T%d", discNum), map[string]any{"disc": fmt.Sprintf("%d", discNum)}) - layout[prefix+"folder.jpg"] = imageFile(fmt.Sprintf("disc-%02d-folder", discNum)) - } - setLayout(layout) - scan() - - al := firstAlbum() - - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root-cover"))) - - for i := range discNames { - discNum := i + 1 - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, discNum), &al.UpdatedAt) - Expect(readArtwork(discID)).To(Equal(imageBytes(fmt.Sprintf("disc-%02d-folder", discNum))), - "disc %d should use its own folder.jpg", discNum) - } - }) - }) - - // https://github.com/navidrome/navidrome/issues/5456 - // Top-level album variant — album folder at library root (Path="."). - When("a top-level multi-disc album has cover.jpg and per-disc folder.jpg", func() { - // Album/ (top-level, Path=".") - // ├── cover.jpg ← album-level cover - // ├── Disc 01/ - // │ ├── 01 - Track.mp3 - // │ └── folder.jpg ← disc 1 art - // ├── Disc 02/ - // │ ├── 01 - Track.mp3 - // │ └── folder.jpg - // └── Disc 03/ - // ├── 01 - Track.mp3 - // └── folder.jpg - It("uses album-root cover.jpg for album art and per-disc folder.jpg for each disc", func() { - conf.Server.DiscArtPriority = defaultDiscPriority - conf.Server.CoverArtPriority = defaultCoverPriority - layout := fstest.MapFS{ - "Album/cover.jpg": imageFile("album-root-cover"), - } - for i := 1; i <= 3; i++ { - prefix := fmt.Sprintf("Album/Disc %02d/", i) - layout[prefix+"01 - Track.mp3"] = trackFile(1, fmt.Sprintf("T%d", i), map[string]any{"disc": fmt.Sprintf("%d", i)}) - layout[prefix+"folder.jpg"] = imageFile(fmt.Sprintf("disc-%02d-folder", i)) - } - setLayout(layout) - scan() - - al := firstAlbum() - - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root-cover"))) - - for i := 1; i <= 3; i++ { - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, i), &al.UpdatedAt) - Expect(readArtwork(discID)).To(Equal(imageBytes(fmt.Sprintf("disc-%02d-folder", i))), - "disc %d should use its own folder.jpg", i) - } - }) - }) - - When("discsubtitle is set but no image filename matches the subtitle", func() { - // Artist/ - // └── Album/ - // ├── 01 - Track.mp3 (discsubtitle="Bonus Tracks") - // └── cover.jpg ← wins (discsubtitle has no match, falls through) - It("falls through to the next priority entry", func() { - conf.Server.DiscArtPriority = "discsubtitle, cover.*" - setLayout(fstest.MapFS{ - "Artist/Album/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1", "discsubtitle": "Bonus Tracks"}), - "Artist/Album/cover.jpg": imageFile("cover"), - }) - scan() - - al := firstAlbum() - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) - Expect(readArtwork(discID)).To(Equal(imageBytes("cover"))) - }) - }) -}) diff --git a/core/artwork/e2e/helpers_test.go b/core/artwork/e2e/helpers_test.go deleted file mode 100644 index 4e99f7a5c..000000000 --- a/core/artwork/e2e/helpers_test.go +++ /dev/null @@ -1,187 +0,0 @@ -package artworke2e_test - -import ( - "bytes" - "context" - _ "embed" - "errors" - "hash/fnv" - "image" - "image/color" - "image/png" - "io" - "maps" - "net/url" - "os" - "path/filepath" - "testing/fstest" - - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/external" - "github.com/navidrome/navidrome/core/storage/storagetest" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/resources" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "go.senan.xyz/taglib" -) - -// realMP3WithEmbeddedArt is the bytes of the canonical test fixture that -// contains a valid MP3 stream with an embedded picture. Used in the -// embedded-art e2e scenarios where FakeFS's JSON-encoded tag data isn't -// readable by taglib. Swap this into fakeFS.MapFS *after* scanning so the -// scanner still populates EmbedArtPath via the JSON-tagged track, and the -// artwork reader gets real bytes when it calls libFS.Open. -// -//go:embed testdata/embedded_art.mp3 -var realMP3WithEmbeddedArt []byte - -// embeddedArtBytes is the exact image payload that the artwork reader will -// extract from realMP3WithEmbeddedArt. Computed once via taglib so tests can -// assert byte-for-byte equality — if this ever differs it means the reader -// pulled from a different source. -var embeddedArtBytes = extractEmbeddedArt(realMP3WithEmbeddedArt) - -func extractEmbeddedArt(mp3 []byte) []byte { - tf, err := taglib.OpenStream(bytes.NewReader(mp3)) - if err != nil { - panic("embedded-art fixture: taglib.OpenStream failed: " + err.Error()) - } - defer tf.Close() - images := tf.Properties().Images - if len(images) == 0 { - panic("embedded-art fixture has no embedded images") - } - data, err := tf.Image(0) - if err != nil || len(data) == 0 { - panic("embedded-art fixture: could not read image 0") - } - return data -} - -// replaceWithRealMP3 swaps the FakeFS entry at the given library-relative -// path so libFS.Open returns an MP3 stream taglib can parse. -func replaceWithRealMP3(relPath string) { - GinkgoHelper() - fakeFS.MapFS[relPath] = &fstest.MapFile{Data: realMP3WithEmbeddedArt} -} - -// placeholderBytes returns the bundled album-placeholder image bytes — the -// same stream the artwork reader emits when every source falls through. -func placeholderBytes() []byte { - GinkgoHelper() - r, err := resources.FS().Open(consts.PlaceholderAlbumArt) - Expect(err).ToNot(HaveOccurred()) - defer r.Close() - data, err := io.ReadAll(r) - Expect(err).ToNot(HaveOccurred()) - return data -} - -// writeUploadedImage drops `filename` into /artwork// with -// the given bytes, matching the on-disk layout expected by -// model.UploadedImagePath. -func writeUploadedImage(entity, filename string, data []byte) { - GinkgoHelper() - dir := filepath.Dir(model.UploadedImagePath(entity, filename)) - Expect(os.MkdirAll(dir, 0755)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(dir, filename), data, 0600)).To(Succeed()) -} - -func newNoopFFmpeg() *tests.MockFFmpeg { - ff := tests.NewMockFFmpeg("") - ff.Error = errors.New("noop") - return ff -} - -// trackFile builds a FakeFS MP3 entry with optional tag overrides. -func trackFile(num int, title string, extra ...map[string]any) *fstest.MapFile { - tags := storagetest.Track(num, title) - for _, e := range extra { - maps.Copy(tags, e) - } - return storagetest.MP3(tags) -} - -// imageFile builds a label-keyed image entry. The bytes are deterministic -// per-label so tests can assert which file won. -func imageFile(label string) *fstest.MapFile { - return &fstest.MapFile{Data: []byte("image:" + label)} -} - -// realPNG builds a minimal 2x2 PNG with a color derived from label. Needed by -// tests that feed the bytes into image.Decode (e.g. playlist tiled covers). -func realPNG(label string) *fstest.MapFile { - img := image.NewRGBA(image.Rect(0, 0, 2, 2)) - // Derive a deterministic color per label. - h := fnv.New32a() - _, _ = h.Write([]byte(label)) - sum := h.Sum32() - c := color.RGBA{R: byte(sum), G: byte(sum >> 8), B: byte(sum >> 16), A: 255} - for y := range 2 { - for x := range 2 { - img.Set(x, y, c) - } - } - var buf bytes.Buffer - Expect(png.Encode(&buf, img)).To(Succeed()) - return &fstest.MapFile{Data: buf.Bytes()} -} - -// imageBytes returns the bytes that imageFile(label) writes. -func imageBytes(label string) []byte { return imageFile(label).Data } - -// setLayout populates fakeFS with the given map. Call after setupHarness. -// All paths must be forward-slash and relative (no leading "/"). -func setLayout(files fstest.MapFS) { - GinkgoHelper() - fakeFS.SetFiles(files) -} - -func readArtwork(artID model.ArtworkID) []byte { - GinkgoHelper() - r, _, err := aw.Get(ctx, artID, 0, false) - Expect(err).ToNot(HaveOccurred()) - defer r.Close() - b, err := io.ReadAll(r) - Expect(err).ToNot(HaveOccurred()) - return b -} - -func readArtworkOrErr(artID model.ArtworkID) ([]byte, error) { - r, _, err := aw.Get(ctx, artID, 0, false) - if err != nil { - return nil, err - } - defer r.Close() - return io.ReadAll(r) -} - -// noopProvider implements external.Provider with not-found returns so the -// "external" priority entry never produces a result. -type noopProvider struct{} - -func (n *noopProvider) UpdateAlbumInfo(context.Context, string) (*model.Album, error) { - return nil, model.ErrNotFound -} -func (n *noopProvider) UpdateArtistInfo(context.Context, string, int, bool) (*model.Artist, error) { - return nil, model.ErrNotFound -} -func (n *noopProvider) SimilarSongs(context.Context, string, int) (model.MediaFiles, error) { - return nil, nil -} -func (n *noopProvider) TopSongs(context.Context, string, int) (model.MediaFiles, error) { - return nil, nil -} -func (n *noopProvider) ArtistImage(context.Context, string) (*url.URL, error) { - return nil, model.ErrNotFound -} -func (n *noopProvider) ArtistImageResult(context.Context, string) (*url.URL, error) { - return nil, model.ErrNotFound -} -func (n *noopProvider) AlbumImage(context.Context, string) (*url.URL, error) { - return nil, model.ErrNotFound -} - -var _ external.Provider = (*noopProvider)(nil) diff --git a/core/artwork/e2e/mediafile_test.go b/core/artwork/e2e/mediafile_test.go deleted file mode 100644 index 1f43a3827..000000000 --- a/core/artwork/e2e/mediafile_test.go +++ /dev/null @@ -1,110 +0,0 @@ -package artworke2e_test - -import ( - "testing/fstest" - - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// Doc reference: -// https://www.navidrome.org/docs/usage/library/artwork/#mediafiles -// Navidrome resolves mediafile artwork in this order: -// 1. Embedded image from the mediafile itself -// 2. For multi-disc albums, disc-level artwork -// 3. Album cover art -// -// FakeFS cannot synthesize taglib-readable embedded JPEGs, so scenario (1) -// is covered by the existing embedded-art album tests (which currently -// Skip under FakeFS). The tests below cover (2) and (3): the fallback -// chain for tracks without embedded art. -var _ = Describe("MediaFile artwork fallback", func() { - BeforeEach(func() { - setupHarness() - }) - - When("a multi-disc album track has no embedded art", func() { - // Artist/ - // └── Album/ - // ├── CD1/ - // │ ├── 01 - Track.mp3 - // │ └── disc1.jpg - // ├── CD2/ - // │ ├── 01 - Track.mp3 ← track requested - // │ └── disc2.jpg ← wins (disc-level before album-level) - // └── cover.jpg - It("falls back to the disc-level artwork (not the album cover)", func() { - conf.Server.CoverArtPriority = defaultCoverPriority - conf.Server.DiscArtPriority = defaultDiscPriority - setLayout(fstest.MapFS{ - "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), - "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), - "Artist/Album/CD1/disc1.jpg": imageFile("disc-1"), - "Artist/Album/CD2/disc2.jpg": imageFile("disc-2"), - "Artist/Album/cover.jpg": imageFile("album-root"), - }) - scan() - - mf := mediafileOn("Artist/Album/CD2/01 - Track.mp3") - Expect(readArtwork(mf.CoverArtID())).To(Equal(imageBytes("disc-2"))) - }) - }) - - When("a single-disc album track has no embedded art", func() { - // Artist/ - // └── Album/ - // ├── 01 - Track.mp3 ← track requested - // └── cover.jpg ← wins (album-level fallback, no disc subfolder) - It("falls back to the album cover", func() { - conf.Server.CoverArtPriority = defaultCoverPriority - conf.Server.DiscArtPriority = defaultDiscPriority - setLayout(fstest.MapFS{ - "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/cover.jpg": imageFile("album-cover"), - }) - scan() - - mf := mediafileOn("Artist/Album/01 - Track.mp3") - Expect(readArtwork(mf.CoverArtID())).To(Equal(imageBytes("album-cover"))) - }) - }) - - When("a multi-disc album track has no embedded art and the disc has no disc-level image", func() { - // Artist/ - // └── Album/ - // ├── CD1/ - // │ └── 01 - Track.mp3 - // ├── CD2/ - // │ └── 01 - Track.mp3 ← track requested - // └── cover.jpg ← wins (no disc image → album-level fallback) - It("falls through from disc to album cover", func() { - conf.Server.CoverArtPriority = defaultCoverPriority - conf.Server.DiscArtPriority = defaultDiscPriority - setLayout(fstest.MapFS{ - "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), - "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), - "Artist/Album/cover.jpg": imageFile("album-root"), - }) - scan() - - mf := mediafileOn("Artist/Album/CD2/01 - Track.mp3") - Expect(readArtwork(mf.CoverArtID())).To(Equal(imageBytes("album-root"))) - }) - }) -}) - -func mediafileOn(relPath string) model.MediaFile { - GinkgoHelper() - mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ - Filters: squirrel.Like{"media_file.path": relPath}, - }) - Expect(err).ToNot(HaveOccurred()) - if len(mfs) == 0 { - Fail("mediafile not found: " + relPath) - return model.MediaFile{} - } - return mfs[0] -} diff --git a/core/artwork/e2e/playlist_test.go b/core/artwork/e2e/playlist_test.go deleted file mode 100644 index d28efca8e..000000000 --- a/core/artwork/e2e/playlist_test.go +++ /dev/null @@ -1,158 +0,0 @@ -package artworke2e_test - -import ( - "os" - "path/filepath" - "testing/fstest" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// Playlist artwork resolves in this priority order: -// 1. Uploaded image (/artwork/playlist/) -// 2. Sidecar image next to the .m3u file (same basename, any image ext) -// 3. ExternalImageURL (http/https requires EnableM3UExternalAlbumArt; local path always allowed) -// 4. Generated 2x2 tiled cover from the playlist's albums -// 5. Album placeholder image -// -// The library FS is FakeFS, but uploaded/sidecar/local-external images are -// real files on disk — the reader reads them via os.Open, so the tests -// place them in a real tempdir under DataFolder. -var _ = Describe("Playlist artwork resolution", func() { - BeforeEach(func() { - setupHarness() - }) - - When("a playlist has an uploaded image", func() { - // / - // └── artwork/ - // └── playlist/ - // └── pl-1_upload.jpg ← matched by UploadedImagePath() (highest priority) - It("returns the uploaded image bytes", func() { - writeUploadedImage(consts.EntityPlaylist, "pl-1_upload.jpg", imageBytes("playlist-upload")) - - pl := putPlaylist(model.Playlist{ID: "pl-1", Name: "Test", UploadedImage: "pl-1_upload.jpg"}) - - Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("playlist-upload"))) - }) - }) - - When("a playlist has no uploaded image but a sidecar image beside its .m3u file", func() { - // / - // ├── MyList.m3u - // └── MyList.jpg ← matched by sidecar (same basename, case-insensitive) - It("returns the sidecar image", func() { - dir := GinkgoT().TempDir() - m3uPath := filepath.Join(dir, "MyList.m3u") - Expect(os.WriteFile(m3uPath, []byte("#EXTM3U\n"), 0600)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(dir, "MyList.jpg"), imageBytes("sidecar"), 0600)).To(Succeed()) - - pl := putPlaylist(model.Playlist{ID: "pl-2", Name: "MyList", Path: m3uPath}) - - Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("sidecar"))) - }) - }) - - When("a playlist's sidecar uses a different extension case", func() { - // / - // ├── MyList.m3u - // └── MyList.PNG ← matched case-insensitively - It("matches case-insensitively", func() { - dir := GinkgoT().TempDir() - m3uPath := filepath.Join(dir, "MyList.m3u") - Expect(os.WriteFile(m3uPath, []byte("#EXTM3U\n"), 0600)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(dir, "MyList.PNG"), imageBytes("sidecar-png"), 0600)).To(Succeed()) - - pl := putPlaylist(model.Playlist{ID: "pl-3", Name: "MyList", Path: m3uPath}) - - Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("sidecar-png"))) - }) - }) - - When("a playlist has an ExternalImageURL pointing to a local file", func() { - // / - // └── cover.jpg ← absolute path stored in ExternalImageURL - It("returns the local file regardless of EnableM3UExternalAlbumArt", func() { - conf.Server.EnableM3UExternalAlbumArt = false // local paths bypass the toggle - dir := GinkgoT().TempDir() - imgPath := filepath.Join(dir, "cover.jpg") - Expect(os.WriteFile(imgPath, imageBytes("external-local"), 0600)).To(Succeed()) - - pl := putPlaylist(model.Playlist{ID: "pl-4", Name: "WithExt", ExternalImageURL: imgPath}) - - Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("external-local"))) - }) - }) - - When("a playlist has an http(s) ExternalImageURL and EnableM3UExternalAlbumArt is false", func() { - // (no local files — http source is gated off, reader falls through to placeholder) - It("skips the URL and falls through to the bundled placeholder", func() { - conf.Server.EnableM3UExternalAlbumArt = false - - pl := putPlaylist(model.Playlist{ID: "pl-5", Name: "HttpGated", ExternalImageURL: "https://example.com/cover.jpg"}) - - Expect(readArtwork(pl.CoverArtID())).To(Equal(placeholderBytes())) - }) - }) - - When("a playlist has no images and no tracks", func() { - // (reader falls all the way through to the bundled album placeholder) - It("returns the album placeholder", func() { - pl := putPlaylist(model.Playlist{ID: "pl-6", Name: "Empty"}) - - Expect(readArtwork(pl.CoverArtID())).To(Equal(placeholderBytes())) - }) - }) - - When("a playlist has no uploaded/sidecar/external image but has tracks with album covers", func() { - // Library: - // Artist/ - // ├── AlbumA/ - // │ ├── 01 - Track.mp3 - // │ └── cover.png (real PNG — wins as tile 1 source) - // └── AlbumB/ - // ├── 01 - Track.mp3 - // └── cover.png (real PNG — wins as tile 2 source) - // Playlist "pl-7" references tracks from both albums, so the reader - // generates a 2x2 tiled cover from 2 distinct album art tiles (the - // tiled generator mirrors when it has fewer than 4 unique tiles). - It("generates a tiled cover from album art", func() { - conf.Server.CoverArtPriority = "cover.*" - setLayout(fstest.MapFS{ - "Artist/AlbumA/01 - Track.mp3": trackFile(1, "TA", map[string]any{"album": "AlbumA"}), - "Artist/AlbumA/cover.png": realPNG("albumA"), - "Artist/AlbumB/01 - Track.mp3": trackFile(1, "TB", map[string]any{"album": "AlbumB"}), - "Artist/AlbumB/cover.png": realPNG("albumB"), - }) - scan() - - // Pull the scanned mediafile IDs so we can attach them to the playlist. - mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(mfs).To(HaveLen(2)) - - pl := model.Playlist{ID: "pl-7", Name: "Mix", OwnerID: "admin-1"} - pl.AddMediaFilesByID([]string{mfs[0].ID, mfs[1].ID}) - Expect(ds.Playlist(ctx).Put(&pl)).To(Succeed()) - - data := readArtwork(pl.CoverArtID()) - // The tiled cover is a PNG-encoded 600x600 image (tileSize const). - // Exact bytes vary (random album order), so assert format + non-trivial size. - Expect(data[:8]).To(Equal([]byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a})) - Expect(len(data)).To(BeNumerically(">", 1000)) - }) - }) -}) - -func putPlaylist(pl model.Playlist) model.Playlist { - GinkgoHelper() - if pl.OwnerID == "" { - pl.OwnerID = "admin-1" - } - Expect(ds.Playlist(ctx).Put(&pl)).To(Succeed()) - return pl -} diff --git a/core/artwork/e2e/radio_test.go b/core/artwork/e2e/radio_test.go deleted file mode 100644 index 73ee5f377..000000000 --- a/core/artwork/e2e/radio_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package artworke2e_test - -import ( - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Radio artwork resolution", func() { - BeforeEach(func() { - setupHarness() - }) - - When("a radio has an uploaded image", func() { - // / - // └── artwork/ - // └── radio/ - // └── rd-1_logo.jpg ← matched by UploadedImagePath() - It("returns the uploaded image bytes", func() { - writeUploadedImage(consts.EntityRadio, "rd-1_logo.jpg", imageBytes("radio-logo")) - - rd := model.Radio{ID: "rd-1", Name: "Test Radio", StreamUrl: "https://example.com/stream", UploadedImage: "rd-1_logo.jpg"} - Expect(ds.Radio(ctx).Put(&rd)).To(Succeed()) - - artID := model.NewArtworkID(model.KindRadioArtwork, rd.ID, nil) - Expect(readArtwork(artID)).To(Equal(imageBytes("radio-logo"))) - }) - }) - - When("a radio has no uploaded image", func() { - // (no files on disk — reader has no sources to fall back to) - It("returns ErrUnavailable", func() { - rd := model.Radio{ID: "rd-2", Name: "Bare Radio", StreamUrl: "https://example.com/stream"} - Expect(ds.Radio(ctx).Put(&rd)).To(Succeed()) - - artID := model.NewArtworkID(model.KindRadioArtwork, rd.ID, nil) - _, err := readArtworkOrErr(artID) - Expect(err).To(HaveOccurred()) - }) - }) -}) diff --git a/core/artwork/e2e/suite_test.go b/core/artwork/e2e/suite_test.go deleted file mode 100644 index 06cc05b6f..000000000 --- a/core/artwork/e2e/suite_test.go +++ /dev/null @@ -1,120 +0,0 @@ -package artworke2e_test - -import ( - "context" - "fmt" - "path/filepath" - "testing" - - _ "github.com/navidrome/navidrome/adapters/gotaglib" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/core/metrics" - "github.com/navidrome/navidrome/core/playlists" - "github.com/navidrome/navidrome/core/storage/storagetest" - "github.com/navidrome/navidrome/db" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/persistence" - "github.com/navidrome/navidrome/scanner" - "github.com/navidrome/navidrome/server/events" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestArtworkE2E(t *testing.T) { - tests.Init(t, false) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "Artwork E2E Suite") -} - -const fakeLibScheme = "artworkfake" -const fakeLibPath = fakeLibScheme + ":///music" - -var ( - ctx context.Context - ds *tests.MockDataStore - aw artwork.Artwork - fakeFS *storagetest.FakeFS -) - -// The DB file lives in a suite-level tempdir: the go-sqlite3 singleton keeps -// the file open for the whole suite, and Ginkgo's per-spec TempDir cleanup -// can't unlink a file with a live handle on Windows. A suite-level tempdir -// combined with an AfterSuite close avoids the lock conflict. -var suiteDBTempDir string - -var _ = BeforeSuite(func() { - suiteDBTempDir = GinkgoT().TempDir() -}) - -var _ = AfterSuite(func() { - db.Close(GinkgoT().Context()) -}) - -func setupHarness() { - DeferCleanup(configtest.SetupConfig()) - - tempDir := GinkgoT().TempDir() - // Reuse the suite-level DB path so the singleton connection keeps working - // across specs (see suiteDBTempDir comment). - conf.Server.DbPath = filepath.Join(suiteDBTempDir, "artwork-e2e.db") + "?_journal_mode=WAL" - conf.Server.DataFolder = conf.NewDir(tempDir) - conf.Server.MusicFolder = fakeLibPath - conf.Server.DevExternalScanner = false - conf.Server.ImageCacheSize = "0" // disabled cache → reader runs on every call - conf.Server.EnableExternalServices = false - - db.Db().SetMaxOpenConns(1) - ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "admin-1", UserName: "admin", IsAdmin: true}) - db.Init(ctx) - DeferCleanup(func() { Expect(tests.ClearDB()).To(Succeed()) }) - - ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} - - adminUser := model.User{ID: "admin-1", UserName: "admin", Name: "Admin", IsAdmin: true, NewPassword: "password"} - Expect(ds.User(ctx).Put(&adminUser)).To(Succeed()) - - lib := model.Library{ID: 1, Name: "Music", Path: fakeLibPath} - Expect(ds.Library(ctx).Put(&lib)).To(Succeed()) - Expect(ds.User(ctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed()) - - fakeFS = &storagetest.FakeFS{} - storagetest.Register(fakeLibScheme, fakeFS) - - aw = artwork.NewArtwork(ds, artwork.GetImageCache(), newNoopFFmpeg(), &noopProvider{}) -} - -func scan() { - GinkgoHelper() - s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) - _, err := s.ScanAll(ctx, true) - Expect(err).ToNot(HaveOccurred()) -} - -func firstAlbum() model.Album { - GinkgoHelper() - albums, err := ds.Album(ctx).GetAll(model.QueryOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(albums).To(HaveLen(1), "expected exactly one album, got %d", len(albums)) - return albums[0] -} - -func albumByName(name string) model.Album { - GinkgoHelper() - albums, err := ds.Album(ctx).GetAll(model.QueryOptions{}) - Expect(err).ToNot(HaveOccurred()) - for _, al := range albums { - if al.Name == name { - return al - } - } - Fail(fmt.Sprintf("album %q not found among %d albums", name, len(albums))) - return model.Album{} -} diff --git a/core/artwork/e2e/testdata/embedded_art.mp3 b/core/artwork/e2e/testdata/embedded_art.mp3 deleted file mode 100644 index 18cb906749e047afda734b62c1ffcdd89ca72ead..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 64223 zcmd421yodD7dUzeNdb`(kWvJc8fJzW1_h)+kOo0=3ZxrFK#&xXE)kRl2?-HN!JtH1 zq`SL2{}+G$zIyLl>wD|1_13$?y|eGW`|RHPoS8W&%fW~N03Z~l(^1xef)EJ+5NX@n zSpYg3@*?0p8F(-4{I0TJXN!UoNW25dl-hCB>JQGzI}y&W0=FCbD*T?<5>i55Yj z5P~8izeUSwss6!2Ay9~*2viXETcIAs82vQI#2K*7o1U?1@0NaQpAp!&J6U4E7&_ZKxd5QrHh;P9F9LRuKucajRoXyNT3O8$jH>Qk zQw@1(6#ycH5`q3h$^W3!(UO)``I`vB|2XhnQ{A1+*jn&!)s%I(khnkQ8(0O3#%xUQi;s8M2 z&ceph`2pu2BJ#RF03XQv(W6KILG5bi?10Bwnp@&={~V#K{hL3SUId^vrzZe#dSVEM z83Ks^z6$-m8Us4O1Hck+1~`9TDS&^{Kmj2@6r?f+ls5AXXRZ%~{6 zcf4Ie-p+snfCsQ3e{=A}12|B|pR6zgEw*)aw!~tb(3WHe7;hjt^aeyey93zT` z;slWp5riNdio^?|MG-JT97;q?1ceqghl!#A1RN^@!y`lm%^^6nARLWB38G;Th#*n~ zj~9huATT5X{kJ6ae@Mbv{gwra!a)!)a}gLu5Gg8#5`^Q;v4Us}(p(UWfZ!20j0h46 zM*=Ve41qvGu!1l#QFBlquxQ0lSg;7p%|%6U2n-f3iuiA$obZ2(!Xj}XSCptAN)!ec zgkzB)7bF&xhetz1ATT%riZuts@EANEs1{2nLVE<8XhA z!u*F-U?Xs_cg8z`W?{~35*33BnnRHw8yF5FC}s`^dks_!C5D5+aBw^nKtbUU7zzy) zgrKmXn{iN-APNIF7ZinHv7&G|9EpSd!z}E7$U-|gq3taG3Ktv!5k-sO;DTZ}5mC@R zXqX@d2Sx&gg+lOfs5uxB1OS0R%|U;O2_li0GarJ6i$THYB1F(AET|w>1pbdI|4(5! zOD8;019f4fTQt(I8kthfy414f+&axS`Y(fD-MBzV$gW}e-rk1Q#{Lh ztQg!J0T(kDM2Mlma6rJopdna{AWj4(iUCEVVOS^tK_juEVqj^Az+s?9qHu^H298I8 zUWGy+VqjgEBmS!!9h~fOuGqgU!=aEOSTP(D%q|p|AYe@iqOmX$uzFBPs0bPZ6U89_ za|jxOf*`?2!l0mKFfcdJqM#cQI8YrtP7H}b{KJi4UH@A#gL5D#>`&0p=3)pjBnku0 zfN+ts3^5nPpmAV2ilNYO92O-42jc<5nS&;R(r{qnfk8tdK^KDI!$QH7#law=__O|g zSN3fF#Dmf-TrFMDPJn|28V&pd#$W(9do0=pY&&is{vR-iH@9(h!2=HFAo>sJ24WrX zPUiMbw)itf|9*Q$_^0n%o{9WxMpaYSx(jeZejjk?9{`5@1Bl7}0g&*&01+7K-vJQ? zIOI1*L{|1M00Nbh`HKbwz~-o|ab}@CXyM-h102Am$G_ZQek*g9qyQfMFE9oGdkp}t z27bR`0J%GYXAsT`{Z9}B{9U>LXx~3!%pY3`i2iA-_FWB`zioy5t*po&044JW_{$d% zY1rTPfQaArz-8oR|1#`v%o*wbg86%jAawbUtolc^&I$$!7CQz8uB%YyXfZ*!xj9(i zc(6px5#}(Q2m%9^1D=!buhh5>K%p=k1O~wfV$8wJf@7g*K`;dof?&5tVla59C>90# ze?|?6ikM^I;Isg;1^XVzR}d{C0wy^gj}Q@sBEgyz{afF$@f=IUk4Bnw1~MF44g%42}~8 z+Z+-j3WfZG_5VivH*0APWmy2YD+DeAEN#&icwq-{bw_l1f(6sk4JG zSSUb{N?Az`T#Nvp&rULMSq*3n*?=o%fY?I%t~3A?hf^M*&z9tzSS7G}fX6%l;NuSf z2O!F44gg%i73zW+0EoW?049f|O3ho~19Ar?18~K3F8J&uuzm3QjGEL%`K~n0VR=OR21ca)3aB~r|f8Ofw!eB zC#~~nVA+^7Mz4OGBW9)xO(31|E!TyTGew6s^M;DBFTJ#8#FcVJIb|h`j9h5ivlH73 zgPj)y^!+FVO1UlOJDzMYC6R7uOI z?og8vrhA;-(d)l*p?NaDd?rt3^;ugc*2T`GS}gW*DmFlf@t~#yOAc|%#n`Qqe=?lA zlzR7gauai-G(e61;88c*`1OLpv|V41+-Z)&_=$|i-M39HK891hkc?xet-LcwoZ5d< zZ1GX&Im@=A~YywE55;qwfiP`JPtdU2(>(E#?g+v(w%LFv{|zDfm?F4Y?lW`(0$ zseyV6#BFMCzZz7qB;KS=m-@EK?FO^CApT1+rb|}s;(mSlWDVWB-Tx%A2&mFVG9vY(W-H*7H4R9kCO5geF?5EEJ!F>5;SLlc>C3fh8ZKQH$O*7STP9Zj(P5?*g3>E23?7`yyUcwQna(9Lw!Qt`e5TjOQJ^`6IV^%XSOGRM>WD+SJ5+sp6cbnGDIM<@6M{%Jf%+ONN)ckG%{m+)=tDn_DigJrZA#wf{+o z{&6*O_bVZXV{E6)*SUjXhGgQNRz+0RmTp_yn}r)8_p5uPc(*TFd994)tDgJm;T5L* ztdfZ<`;vuAq6z2GjNrKULXgB4gT=_C*BYXd`bW=QwKgNp&$;;tW$hj^NBa{od-n~0 zRgFj(wr8}T!g77;d=gbeHbvztOWbuzsj8hip50IP<|-1*kIdB5nevj{BK>zNpXlE^2`WLv zbJv`w?IlHAH{Kv|zlo!7$)7X7opnh{BPpmUGSYdr{GodUTVNvH6N6Mw)~}_~3Z<WQ_MzbYB1IJO+~>q^L9t&U#?E|Nk+*FaWrpHb&(mz zQ)90F5(_Q)+33`Bh)96QJlM(N@MLr!J5886Z^r7?^=t1iB9tSa{B5Z#$^P&S%I4N+ zWU(=$Tv}y&G(M`=dzH#KQ>+S)Pb`9u1#T2Mz>_ldsjjL+e-_G>KMQCO9+ z@Z?&*|6!Xn_U;cIPfJq6LyD>0(_KxT@(gv- z8754TFJ$Jf``K?Cm$OJwSCp!kNi`u@`RLFi8k0rj_qCE+s|3y&ENIk}zjE<|=raX1 zNryY>RaP74aLYu)YPJHI)jvHcU|Nf-uwDSqBVsTOhgnLpeV4 zcpVFM#7bMcehECp$gLEJin_~Ex}p~ zi%V8MQ^&b&I@_a0dF+RU?_cG~CcMABylce17cI?EH{|zzq}1ut-mi1O-G!^NNyeLO z7Sq;RJxlmFvx#@#UuZR4^3R}nB7&;!7k^|z5P+0m(%|s9DUUm^d9{`x4QEuSiC(0; zh(DU6Pn%tRD$*}R*i4^8;-6-NWAfuvJRD)JCEW8BCM&=9Q)*Y?G%tSiRx`^AI!ff$ z%0{)D(=+G6gJUMC+l4$b+&oHTj$acu7RS4aXtU0ZoD=b;54q|>saO7e@!Bj;n$lbO zoxlQWPxz98!D{R};>2hHTvIm>q$pb4D+3ymw=sCE<3v zuvcCp}MF@?w{H6Liz~kdg#gU=ILsD*ox69{dEqGjC2A5Im z%S>~$K5ry>nfF?C@=cuniL8qa&DS)t@CM3T_bz;9n!h$9?XvD1Q0Hvd{d&W5<16B% zQnfcV(*@-b!MfmfUExuK*v-ktYMal_`}Q85za|2%ZX7I9)JEa2XU3F%9Rhaef9`Jz z^b@gXFk{s0Z;tiN&1NLqS?zKDkh#)wxE0(h)T{ZXe;Cbtnj;gQWBoX>Pab=LAvHof zHHKQ~#r$3cO+viV6&fF+G@S`{J!n6vnS{I>hMa+n`^8Hq|1oCr9!a&r};1QNe1Q(rrClP@wLmL z0{t{~bL6SKeNF=ztI~pdHeFK7;_=!O$Isp+3SFdbRv4==IPpn@${AYowYvtZH2Dqe zN-blm1USs;Z_&x!xN&2lyM5;Y+=^MB(Q01K=H<|uUiM|VFK`>E zkbd0V+#;Jc29sz%OEuHz^~00~8p*ifw$TmIBhr`x7cGl5=t}a zUEWeC(Dss>lO3e$(6v@Y#NLaUJD>86XOHU=ZT+6d(8UuX0_64gZF|jJj!eXs6dX*b zC^gtLQ=4-vBaxMnw80PO;ULMUp(3qOChG+!V_K(1B_aetS(oT27QQ_u6I0=o_A4uA zFT9v9OweZZXfC1Us?LW9azK82Ku}clqpejV!sHO8ruO4vEF!nc#Du$%P`uciZu zEPZIx>S&PRpph_PyB(iqZo&%#8FaKH0PVpMRp(#MufMSCDeS7xEd0BKIJl}<0aS9 z&&ZYdac!pQX!P}!isg(J;tk`CTjw}*0^Sn{VJkl-XC)iH4kw9!e#e5ami4?9I!=Xg zwUj z-QB zqoba+`o+>F@ce1!SF+MJ#q?u<&swIljFUW;VSp{WLK2E4z^NleW488jcVfdgk#^k7aAs{{F{OzZ}b@=oXKI0NGRH z?md1!%F*%Ds}xu$IV88DPor`G?cz@!wMF<1NFD9nn*ZsZ+w{Tm=Yv@Sx+&Wm*Uk7B z;%7P^v@Kf9zm?Z{r3w_0uzB+_v=$>Zqh0bPdK%J^m7CFrE6x6Bkr?oZ*n(w2JJV&)Lxt) z`C0rn>l8CgQvPm?WG{#=U#(`L*LY+=zE z=HZ4a^N{@#n|thW4sRh9JqcTwb4UL72nHkT?!nenAuS_me=32eH%S`mh&6xemPHL#P@u8kkjVDT-iNHqfuyxf7|*2pIcxV`0lijODk6+Rp!B5o7drvyxC78QtGMJ zSE7@@^6t%wT6I4=x(R;aG~qV>@T*LCoUo~htjmtkV_QIu^%v_Hg^iDtKLxZzA|to? z!a8h_xUFA1HU#?Q=+SJ$!@V_hjl;Oio7b=3ZPyoHXB50&7k(Kv;mM9|c<>Vb**sp2 z)huFXsl-Qq@O$|;ph9Ddf|~z?rc$Q9k5Ag=x~bPTgZ=P8HKkV~!gjCM)O*=Qs9o~V zW-nqR<16u}Ttf@E7mi=XQS>V@tn`u%a>cbOrkn%pCY4g)fUY1;(OC0Dq`ljM?{&%t zMF^gZx||N#+n*ZLu|)ZrCl~e0q69-Meyp93Gp{dyeeY*I98T!W1#w79pJKvK`ibs*!rBbq+juZf}bt%K!@gD)n9Z^Q=P3r6s#_`HH4aXrPjE&@0AxQ9g}Al_N~AO ziO8hr`YH67?Bz$*Ycq@^o||`h6?QsgX(G?Fn%%sy|0_LQasg+@IjUun_iN3JB*+07 z6?6LnVCTECg7Bj1aAuJhx_uJDa|!W@FU3l%RnNvp;tq>kbVzD;d{pVvCY``@I-yvV z!`>~K>2g!g^#Ho7tnddB8{*H73ymtEzJoyp`o&i+p6DF6cBIO_ZlOBw(p)MZPGmTl zTqDiX{XEQ|Fxo&RcS7T{L*wBvUXs^^Ak?dN&O3XK2;d=!4x#GsG|7=T=kz$QG2x^F zU;N~?*6|D(-3&JLV}sT$8!9>BKC=ayE|C^|m)M~@L!+$2_<`?30B{Nz#* z{ncHKGTC)DQ*%$+&x*PnzO_diD!dAaM{8p(H;GNx!xHDBsB5tleX_&PW-e44E2A(a zm(ov!s4j5{_`g?0$!`vZy0t~8!u`81e61t6uJlYu4Xwc`X&fZY;jvUaMeP^X`$8q) zm)`&+u2WCEKdD27jbu1Y*X%%xziA zs)ByhIJdALAMkR))g(*rPn;XPk|XBy&9z z(F382R&N+HmD;jjdDW>mj(1xTd*xG}Y*|}5Z)JVtlD_FBkb}ihx>{$jGxusR=Qj&o z6%J+`qNMJ&B3a{K?xll}UVAe8X6#*&O-jdSjvVCYoN3^#;wE*G?lL zx}kTMm3uU31%O0FrS4}d-yNfk$*0Q6Oe>pfc(fd{I(Fzu;`5g8V7}VJyT|PX`^9$lbW~}Q# zB;fS?iyCYB<%sdaBw{(7N736B#^s%R5%(-hy@8!%v$xfp8K&iD4Vw!Kfl zc&XAIhHD>`eEDNkC7$N^j(oY_%ljaNd)R zD0iXQ&NTVLy5rkS-<~^hn)VshY^jLvH>Ky$c;<=F!!Bn!&fxS#Spk=+rpW7^Fu=Ln z-1Dl-c4plt?+cLRwD~dEm|Nb2dOUEYw1H<+ zR!d!?b~i!hB>xB-(UWyZ;zP(}-`7M4nSJDchrovDV-WqMu+GvGuQgX`%@7i0R%Icr zb|zW&pYWADoLYX~VH(6O&x&YE??mL%8^5|xZE}8GIeAJbi|9lMlkkkk?ux73-X*$# zo!hxA@%JhrPh>m{t{(hQ$_GEbq9(OAtJU^jRifS$4gk}!`#y5p=T$Op%H5))*KzPn zdvEhASNCaU>!iJAT5yxLo?cwV__tXk7x&MnBi+TW2HLkJco&MrlZ-cf2Yc>Eom(f) zii~ZRj=;3VP({$Z;x`_tEcyg~@QHjiR$X1KL_~c-`{`?2@Doqu92NcV&#in7Usm0g z2VzxUF~A0hn7--KMrKu{(5^^*1qy=i>q`BqCR2z{h$FsmpE>U`v52AJnojOQOCc_r zMDdnXD!-V<-9sp;vd*;gb1f>yP$pMXy|E9f&M1RlE}NI-`+||4)jnF^#`)IJ>&Q5zjfEFJcEnq5sBT6Z>T}5)z z-l+!n!0pXt?sD(Bd4&cOuauRmW`o(PjVj#69`9AVdFl&><0=w?$f6LAvHbhdb*z$m ztDO(OWIZtIT@Ce#36&#>^t{qbv#T8*BrIU0_H%@|(Rz~$dlvzuNqfW+?Hn1;*!lRP zkuCx`zr;@hX?%q*JAETl`8NEMDMlqd@k5y83MjgfnCb1jXZvBO-OzB%oVupF28wUw zNkRl2XB8#Kom0bBMGjR>uV1zW)p{>Yk;c(qlTqn2iJ^HR++Ru{%cnl^7jg%>osF9X zGW^^Kd?)U7Z5N&QxtGnn@zijNmZE-r-Sx{jtB7NwTY477OE}(6j5MJdAf+@DlWS9W7B%2muG{bMML1HP{jL6Q=3F z-sKneJFG5K^plE{^bp}_gIBfuHC|Sl-Px@bS~S?xh;tF&2kMI~?~jRoZ-XjcTk@f| z60~I}Y3I6KIzEulW|+J`-RTon8WYaW`PDLiNp$^r?z=#_9V@v9s)NRw8)Rb0{+OXF zTY2mr*&PCm8jN$bgn6I1GI4>2)&`fBzk5I<>=g^0b#Hmv-9tDP z@pzXc7E>?vV>>2L+K4{sx*FSU9}Sn{ALzN%>p;yfv#=@YQwF8<0}Hvws%hG0WJpmh zn@bdC?eKYdYtMD(P0D*(>W2)sF%7flq6~RYRotG6kk_ZBKFe9ZdP}X}3{gNcaq<|q zz?I4iYjjH92?S`?0^>X>-rio+G`IJ<{K%GN(0JSQl{LfntNP`VV=P_mr*@3Kp#Gl& zGWehB<;sZO_R=q?MCH;4^J9-XB$Y9FtQ?;_vb`{pl4OO=zm%E=lTR2R%cP5<#8Rh4 z0;+{QeYXzJe?(qT>BoI$-OVum_Hor{w%Y&G@n&Xa$7*SC6laWwi657kX0%~(7~2-1 zzX2iuZuamtFITguPyG9evLB~+bL#uhzWZgd2Hah`o=-k$Ht>erbUJ6zN}9u}%6EJ` ziSKThNf>V(&0s1vPmMQDA&%s~qs)W-sed`+Lx*~4^!&D>eG>X$=h>VOVbL8wGcxTM z+OYeUwfkySFWuN8Ec-EukA+fDb*t$?rP!-X~08Vlh8@*SW&4=kxQ;gYc1zAQGhA~ZCT+jrU&xGtFtS7<%oJr_K z@Rb|>B;C@^aPSAD3U(_I8mm~_!gEE5Vb4?+h~sXf)aI-+zRsf(KdsB=Oa}_lzcY(` z{ct?cH$$(zYBS``uHm6|s*i%YBbgWKetE1$4qIrgl}!5bgmzc_()|j%a)t0V0YMUH z#DM`)m6l98jr%9D0Yq~+rB<70!U@6fI~zYo)@q?w=nm7g3dzjr6@p%e03boX_V?T1Dp_PT8=_zfet$cs($JPhIf#(M%loVIf>_xV$ z*}WZ3h%1DQ5chSKuBI~RN8JhQkl==oWq6yrVJPiy-X`)%MP(Ll4Sq=o?%=p>YIdJ} z_x_PDY16`+Q&Oko4>Gv#(#g|xr5j6Wwd@WL?&?SVAaiF}`|r-E2RkTn2q-DFSZRPUqk8T5x50Pk(WW z^bODOCCFk4(jEIEJMGEf$5Dcb&9~pF#7V37wqgSJ4|KY-wC>!1_D6=_-Wm`u>Cc+1 ze-J9e)bZ>mG`}AEEX#W(tDhaB_F=JxbvpZwa?Z6a{;p8M9_u?V7Gg<00ql7)?||jF z8_lv6lwCx7M=oWvC2|vb?0Uop}pm$d|SMJr!W+=Sulf~mPn{hH}+2MC^2}Y95fd? z&jb|D(Y8NI??G=!Cs1D`bih?Y4vtC{L#|j=X_l>q8Hfb8Fr;xjWma~bB&~gN zg^DAP{5G4I|JQOUozsK>BUsnoW@bf){fo7kVS6>(wp2Nnq{d#7f~h*uao_pdKV^1z z-QkKZEI%A_c($n0Nht!ctajgV8jtxPj|gr(R|%A0mNZ?AJxz7=W$ft<(8HTt@zvRN zZn)%f`?+>#lthd0ZMU!&Q-A_ncp-OBO*@-;C?ey;_yNj(Cm=JUL%xBAjpSU{Tb+JD zNvMJhJ#h|z-d`g{tGcAWnDz4EB6N7LSwZy69sF^)&-bw}QA6r2`bdA*BIkEa_8Jto z9ZdW0;>XV~dCJUIyhA8%$UYp0%yKB-CxWLRp-aZn;5 zzI@)nN7yHXBf%c>_USoh7oR3N?r4u7S)rbu47EL`W1r01*Y3S1e^790HO)TubIBtP zC6MI)tGcvH_~2Q`osp#FX~NXRd`6C6YCabpUH#Z%ke}j$3D0B=*x8s_R9GPM>OA;N z#xdYc+cL5@CR8ewQTv%2{fem(sx+s!Na}ZkdrjAOxrOzhka?^KA!!VdZ!(9k&g@lZ@G1(`olj8C>A8R7Ln^Xlis=?Nd|R@`h^Wm>hp&SG zPGDdJ_f{b$ChC<-E<6dTH{wxFsx|a$zn4G$Nrv@N$sGxl^4w}dMjshX0D0PVxj2oa zu=fq@o2VaN`t0y8fq*`drvsfg+o$rivP)NYxll6+bW@@Zf`(4~m1+8xPs3-kgM8Eu ztx}Uq zk?*+lH8yackI|-X>O;BNuE>2EXJK-B|NCDY{jNl;%h+E$U`kboDids^b}Xwl^oHcQ zOI|qc|2Vf=5O%U`nuZOfd;DaJMN)2F>4XF4;ELF379|9CL|r@Edxb;0;Ki{{c<>DX z|EEC&+zuxQh3SaE#YM!#MN#|^h&Tko^z^j#-v-#)<1EeH|N8**Aw@@U0P~pyZF_T< zhiE4}@bKY7Axk@JXDr$QFJ$jzk+^b;1tbEL<<;a0WXybj?^`2Kf`YpS|Jt`^<$$*U zpP%-fjsX|$C@3oc1OxOjkb)02F93vuga9!SG08bnGEy>-kPsjuragC&ON35ZljI88 zksb=udK!6ECbjqr0~~?r>{=t$b#ifyd0)cFEh_6gzJ5(xM-S^8oz5f|C4V{qkex}A z0*;W^9e@bf1&*UnyEp0koi<0LXurRy{9sv)*-T%l%Hw8 zO|<(In9#eo+eS-En?BDX8414Af^YwTX}Oah)QVg^L(|ed49zVpIcdL;!k9K3aq}cL zi(YPcr&t$O!|GcMTsYn}a0v{-X*0wy{Q1FUd$h>x;D)!<3|6CLCG&)slXfXUtL% zofB+qDqI){Y9I4_wgE$2u{#vV?O9jDlNup55I5OWug@I^mVDlMVZ^|e+fY+aof=qg zqtG~xI+XvmzMS<2`SoW!5hP-p<`gJ=xXAmX#Zyw7kY~4ep|BzUd}>f9l=|7|@lRFe z8rnz6#Z$TA8EbiuBg9J)-dOK?$Q7Pb;G*t$P0QMnkOmY+-cr8!)-+H5s3LrVM+mu@ z>;03X#>&Ga_f5~bt_FjGiVx7iW)9!JS-+!A%G@SuU}SNl*lpDWS1-HmlAY=#zP;RK zK6R+u2u*ZbH~Qp$bpOJH4~I%M0>*G-6cdR;Bt)#Br#uxAh}TWuUf0-&M0M7)$#H`-DWv2A`2$hoaB4 zlVScgYCRs-jR7x6yl<-?cX_)ho+LV+0w+?ZKv(i`z)t(~iXPKW=Nl4>O$`d9JA_h1 zl8&mE1={Gv+9}?4>KEz8J~w=+HD$xVTX?;5wJ+EY`RSFe%@b5h%3@%jLL;`Ngyafu z&Ulixu)hZbevgsj-4jmQn-)=6tI}|{y|6h`20t)Cv^@7+Ntf|KQ7XYkY{Qh>Yu>c>$aGR%lZUl?X}e}6yM-o zi8&WETiE8l7hJoPJ?LnfZ34hEFc7GG>h32~VzD(WQ+Ittyw90uB_eoEFi0hiAWsD|nZax{aNKQOy@kUP8E)7ae zC_km8d*ppP@i}IbrhEu>C=lu=Bo3aBt87w(g z=RMI@my%GVL@y)BE;CL@#W+eo;XtoUR%xj(n}(~SW|$2w@h4G0DDBNXdB$Z0B`wAN9tZ@VM{KiAkNb?sx~U%XSS0briSP2M&ctZJh!u zZ=Sd|oVcnVSRc+5ew6Au5j=T+3e;d)Ygm`hxtfKs)))0k5hz=AmtBjA9!huj467?X zieDOW9ARQDBM-MKnd7E8*&1DmDCukCX*MNTe-uSs_4ShtSPjYV4tW^54MVMUz3g?L zyJ&X%N)d4(y+Tgn*J_W@hcD_%mN{n3dyBD8`W#Z!YSqNsK02^7+F-mgzp$wvw~h`+ zo=A^-AGP|O0^?PO??;$QZZXat3x6mtoY#HO)V_B9K=a1oU2{EDdSDXC_#S+D{%slZ znYssM*bDWy^HaS48RcJVEw?;J(aWj@Txt}Bw8Rp*EtmGZK6T)wrr4QIftEh06V}=) zj;33}r$9oH_YuAVL{k`$@bT|YOFo&pU##CP8?ENv&0&)!5#e;I$~(vJwwf3hI#CUyPi zv-JgSsyz#$%wh-DW#jO9rzb*QQn})uGxTy`9pWVipPvFHP^s~kbR8bdr+{t$Np|!p za6LOYI(Lw-R4}Jm{{^#w+Ow<0Z^A>nlRN^!?sr~}@^M6xLM(@FyY=lYdnwt?B#F>d z;N#LMpxtx|#QY@WO&OCqRPlF|q<-A@eXAOM3Uo`a5^UZ*k#S!m7iUx5V*i<(l0|w> z_Nd{Fb?L10Ve7s}-k!RY_@uXZ_Oa;k=Dk6Vd2hy3z`%PHy0&i9){@Q`%4+X;-)7F{ zH96(Hr^iy(m+W`XA$xx5T-;&5%5j){ao=n?^?P6U{0_ClEl4LC8=DzmXs18ZW4!%`c0 zl&2hhjRKclR^A2Y3lc*_YqAH=2@979@vSRV-`3WPSVJBC5t#J7M|+>=ruxL^lCQBL zyvb!l9FuBbQG!1loBiS{Bb+i-rgA^|#9TR>dEY17)_eCSaICeE&q=)a6QJ*B0cx-7 z8Z$C3M_yo}XTtVna4)uVL_@TPSUCC$^*28ju7 zy34J$ZN}ksuiIb+|JR-@On0qllka=s;!jMUq}ht~p<&>SNt)MT$bdTUMY!0KtSkQ} zsabFUUB@A-X5RDudVyWXN3QCwVIKA|0j3Guy3L&H15e}yn;1$MFhgLgurNrU%U z-xhn>bDVbyWZU1P6Sl0pJPESRZDM!7d4%cB z{t-S;)uCH&*rG-aiauhs7WdB+&dbr zml}yajND4bd$%5GAJ0ow@Xs25Hf0DTVfQj%z5LE~p;_YbIl3!X9yo;YREuo%=OMhV zGPXFTzhh(>YJYW$F1V1q-6k-8)lA*|HeX(u4ZK#obI85)b>054cOp$iKQL?+; z#7nu8)OSKeQ*PNi>Zua34ZE*ac#{;5``%qAopEd2xih34-k<#9rGEML*xJHif@98T z^{9K_Hg+9$#dOS5{*fYcUAtAQZ_o@Mbs1mr48}`s`h>LZp1)(h>Tdo(`3@-`b;uK# z^!rb7=G<#5*WW)p0$nEQ#oKSX=}mtMJXT*(+z2X(@#uCi${TWHMm<@bV33wdO0O5| zxyQKHHAD^{fT$?>b&F+?>}g>j}_rUpGWVuwlUY2<2^67vDS+~?X)AH z3~x)d731R-&A$JeYF%EjrvP$YeK+G|>R%tI-V9YZq@wttiEX;P^mMv}>ukpO*JZ_D z3yuGJ{g=7rzta5m;QwDt1;)l|T(c_sNQk^FJm1jT!8}?h+>6*UR>tCZ#HVzvhBa3- z*a};edcWQcX`Os)JolO|$GqKqMY^=6jHT}ejY0eV`FFt+xU}x${Aq5Qq)v5=y>+CT zEn$`9yUJ^-IrYbJEn_`=pJEs6dQz^bdUqz?Rd$-{`2`t;Xs;a2b}oGa`#%{@Pnp#u zXk=gI#}d2tjp83~xmMiOt2XHh!G&Q$bz8bnly|;T0b~ zxoNKDNtd1}@kHt)Zk@(L=rdm1NnigWN;*kktmmG*-d?VJ&~57vV-8B?dOZXsX#;b- z17fyieHs_w2<5G0r$^RUe&_r7x)m;@pV?0O>n0#MHZv@|_oG#rT(!t{!+g4{>W-7k z0xGYS^|btGxjkC7mgKl6pFltQpp@|EyVZ7&lkea6Y@hSrT9946ztPz}SIs^{9LV-j z{NeI(Ny?a;M`BH>a0IOs2m2@LN>lU@}suODeiKOczrvPRB-O#0d0#pGF zgty*!pNe^>jomuua$iQxR*GwHe(+a&Z?#LD{Z|~{M}_|Q6ge+`OdfEh!GV5jS*+jS z-3PU)zrRNK_b(Mp$1NX9#A#%Pnq4H#*t%5VI&EoaH1IS1;eL~GV7D{)f8oWex<_)k z893oNmw19zzGKT?#=+=2;ps#CZZQ3|A23IR&()&ci2WnEcL9-J6n*ZOo20ww4zeDf z7f5tourbOFh^5WD5H{L#F>!5X93|7NTkutKIhK9l*0jx%Y1&9go0`OH%W`)PPK)$$ zW`z#^?~4zNo!#}NK0e6hE2+!!oM5WlJ#{bCR*$C|c~f>uF{AkTvy5SxjR>`$ zEsujsU&z0W=Y6>-J`h-hlJ40R=ow_)Q`S0a84G(|*Vckb;C*T7I-=1~JQ{+8dFgbp zm%zfhtqOTM2ikPebK=`?U<2o^Epzj9h-$3L=DWj(A^og#4BG?Q{K-%INpvS-_8#j0 z=nx^Md8&y*PZNJwO%Zj9UMEyeGS{@kFobZ8e!J~WZc@~c0Q>Zyv;~1Dc8DY@L?;KsFthbZDZNj@m=jZwuRFYo<5^H&8@mM*K{wag?*4vKPgc% zo?s$=Kun(6$3|D%kJ85TOgpJ~u@PuI!7c7I;YxGkqJ~-;m;ttxee8VibHaw-Y(vuZMx@n7uVq#%T1d^g=nk7R-GpzaRW()_)dUvNYr2 zaFl1Gd!G5WnHN&RdM%$CrP=&VlH^WkEW1;zx*bD^!UL?y@ei`)y|&%8=NtM{n8SEp zJyTxxH2kQab<}p~1+VE7*uDOfYg-;+46{a~_mcOR-z5hr7|RC_hpfaA6;AT6Wl7s} zImPR_yLdnncP6}q0@m`UDxTzQ?=jE*VqP!&Tyfj==9JX8@#`4e;J2Dun1+6k_Q?BN zb=&%7eV(`cuQV%lUTwSI^v&}H={rD!28vvdoo}vUUw{Gvm20b}GPh>mj<>wkzdmfg zO5@*4!BIV>9%N?lqhYErZB z-QPUNpeN@^SKc`DB$EtYczqp1wy}J}@tU<7iui#}ZINnQDXHdmY40XoHZmxXL2cl( zGzWnIx`&!eXh73!w4g$@>KnwagpT+6K{wMk){XvA^=7d!GWE2~>_|AJb;tJn9y5jY z+Q@F+sNODpa?;cmuEv@QLf}U?-{DQA!p!ihNU(!zw`?zT&U!Ox?QQYEEWShja_jV3 zlI_f_Bn2bh{?R^1%(9mO5tUB=M{}+d;zjHZ`7Xn`Tloul`nI&S?-qy)qZU@J2z3i? zHFgMl-Pzl|%J1W`)xQ~;mi@lRtnwXG!Y8SQDU<2^^87^$&k>7hzM+}MS?v4A32lE` z$-jT;$P+})_$cnx!aAVYQcGSIkZ3L%68U@pC%G*+@>s4tI}B5*>Z9+Gx?1bsUN^xX z`p{=dH)0M^i@u23b0cMb8QN-a@P$Tmt$W&-!BKi;S;!>CedWGUV%`V6Hui0;~xEr-0m&kTaaHexvn*sYU1U^8ebA_5TEeyS(xB8 zL6gJ{-i}ki=F^u)v3n`k%nZY3N7O|mYR+v7DkTme)oKFeCex;04LK!W-L31WlMQ#; z?-{1~YJ;t-J9B$0-MkRV7RIuQg>W)RWKXro4n8odriPjt~4y+$u% zw9!j+MsK4Nof&N~I{B>i-S1lO^RD~3{$1CPS!>pLo_inr*vCH3iqMH-Z}R@xR=R~( z3ykJ5m8kCVb5yEDv5YsovJA>-_j4rPZ2I+sf1&e=xQ=BP5bARRPv6>~Q0B^dNVK0&9uWCG?DoZs z>oh${pjJcBnioA?$B9f?ihO=t&*TV~xX9wC+%JFb` z)|A#W*`ai!8?yc`qN#+w%VKUtD7Rwbie(ogd}(4K_)&qgs109(W!*FlMDnESD#bE_ zFwS!$JxCswJgi#sP(g>YUFsj$_%!Ek45{gX!&Sxa{LR~WDH?Qh)7JEf<}x6ntD+YO;kwSRebg^r9O>Nu!hB)u}Y!s zIN?phMpks%8k z>w=+0C+hwY#!cRUT=}?iL=B4pSL)QCj}ol<)$kP0fql9yvIWU!>Ffrf-O&U9A1a!d zY?Ma+?CN`TW4(N;&>{s(0gK$=c>FFAt7x0na-AeRi{0zht>Bv#zVOdV3Jgy$B+*vi zN$Ihxqvssd_*!p2G%|^@sUqHu3fa1F1?oAN>zwSq+W9!1x{*FqB$N@^ zX5VA$A&7(@)5XU9|GNLPEb|Okzf8wJZ;C$V9ny2MG?mMZY>sL6CDklOuZ8U#BbZ#CN2kYHBXb!|qg?uO^M$kX%7A z8G+YCD_r~6jNz&9$c*zx_}%GMg$UJVicK1+>J>7U(NjIbI?$*0o6TcBTv;s=e8!x*x{Dic$tL1C5em4zEUOZ>N1exZj zcW^vsNqFfMGaaKO)r%dy>;fXS13Km(NZcHPSO%;r1~fTIsv2kCXS4Kc%1O_z(O+Ca z+uBc_GXW;`kW-9a09Q6mSa?4&ZPtp;CWF&1r&*$!8`;&UDU!X^+){ZW|G_YYt-S72 zy4k_@!;GDz>7$1PU}X>MJriu-amX>skw-oBxq?uG>^1i+#N_4Av1(^>XnZ!VtX#Pz*$h!R%02)e}CI77sJtQxTIgbt9ElxfRYsScWBDT z$2ECm3#n z!X8fd3sa#6DnwbQ(V$&FTnWoAWt+^#e~wt+8&xx;-z z!YT}VXE(cvC%`BjG1JARYAtCiBG@5z+*b1vDNQ#(uOAlRap zmaEX^npLeAr@exUNb7x7B|I((+Xi#ez`mRD+s$8^bYc%4=Q&h4czNoh8<(DT?meL3 zSyYIVnEKsNZlqbYW(f~2LS!1~iQE*5xR@S$Uo?H1vZF7N%XmQ|!eYc$OqeT)2la>A z;RyQOc#mG3=QQe(=~poVE|5W&>c1n@>^2b)*_awZtA~3?r3|tzIR;cjx9nMTlJ^IF zFRQUc$q$!29=ELC1ib~tmtn^ch(WKsN%?y~jXLCR9WAwXZ`Ztd5=GqcqEDBR$^Nw} z+SsGG8JUj7(LcxykFR?X*pQI$CX%ZOA=`UEL&#yGexqUB>^lJ^^2*F)KQT~B(=82( zK8jtsR8WhFiyl&=`*RZ#tlRK_D! zxZS!qp|u*{EI9OH4>FPfK_s&d5(2(E%aBvo)QT>k%6d^VSORI6peOI$J4Zyx^5<0z z=8$d`vL&k8YUnUMEhz7*wx9IU{DUz8c8}rkH8AZluJ%Q{;JxYCS5v?3)>u}JebDjE z_CqS1Sy+I#5~Y+bd`Ihos}sa^jGAK);49d*b{^ga`kW7mSrx}8TN#(01C1cy(njA^ z^0BIXZ&Utv#j20EX-!?bkoLZeUdyr!8y_cCiZZ`3biBd?)%YrcJs!b0nn;42Hdr@I zN3&$awxv}H1EN*TAB{HQZw1{@cBjNpY6-i6wxcUq!>)Gd#@bAISQj#joW)n@9|GeN1+VR{=qwPtfZKKo}&OR?W{bB=~`}4f1 zYi)rhv2G&lOCYP;)@1+rAjeaLC68cHlQ2C~2K|Ex@%6i{Gc^l-t{>SgEgHKE;_cBe zoK?4(-bE`2k(jzuNjZRE4uY5)MzYq07AN_Gk8`%-JMn4nQ3dX4m-=e?`ttyj3Qo%k zAE`&G1g|1imiP~NihDaZB>JI3k=g;@cc_^(0}T1@T$!c+3^H#@puse8{%VC2P7Sa- zLChP4O6%Q2bheAOG)jjbmTw*K9vWD>d5lHTMY>d67Pl}U=0&jcjt6m~*~|(pROTcX zf>;-#nCz-aOor?^f_P9c@x``xbCqYK|KX*qJ|@@*Oz79QSbFri$ink+21Sxr=Q=%I zrVo`w;GbDn3SBL08q$?@=7ujGi&P02(Wpc6MYVM%lA`-zb93!xmdwN-ZurqBL`pII z#^0>DxZfx@P!yMVhs%F2129^#Rxx^BuQ%q(7vA?<5b1UTHnQQ0Uq?-eaUL>Bcftuc zL!U2fUr1B5d_n+k7hmTU=sRXU@avEy8 z;Pi-8R7RZ18zkpk7N%lg?)HK~u6;wm#L4Ihz5ht-P_@6@X~12`%mn?-_@98L{D)jp z1G47K%Z{uLH${*kD*IFG_|qvA`fIfp9l-pk#~I}rmZQl)om+ke?_5ap9PV?ApZI3& z*vwNlZ*ukZb;NSQMCil96N7%w^?*|Y#ZD^=$#W@Dw8}Xa%VKP<8|0-;;kQJ&<{q-e zuZpT*tz^obC9ELwf`-h^bjQ94yc0k6n)aoVV52TQW&Y#5X1%9SP>8obExm1Q zf=X*j+ zB6B%UKKQV1+V9gXRBuLS^PINavz#cMHZ^e4-5NiNuhk?mxEW`>wSGp0!L;7aeK{{i z&<7hXXd;+9gMosx%-Ol8p&$VJHZNIct`*rbtvQ6&nc4>G}9X^RTB{oe1oI1nNRt69XA&~qq zrE;B@U_7+MdKzhujHW|7-Wy7Q3UfYNepU_`7cy!o@$6__PSEy1@22|tq4?HV{=u)G z{}Y~!{uysV&-dikX_}B+t^fL3e0zN6k#~i_&D>V4xoFUJ?Ctik;^FS&kfwm{qJAJH zME~yf=ZJ9NdXD&L>0_`<0m6(*vk#?-eW=8L!i?U z32oMEyO8 zQBhe}@~g=|<%xXe!Pc%|3cRXcH(py%{^IdPQ==`f8nW(sl?>ZKZ1MSv{>p<}=?y8} zGy(S_B5HG%(Vxm6evceNoZ?e?lUgLT*O5*g6X8#Ub zXO1mrih65xWFBV{o(?q5F=%oI9l>g_J{xR@bJ^*c*5E^;1#R2LnF`i1r+c(o0&&$z z^zw+d*LveFiw%a?_avSHg~{7_6cw9mkJE_HnWIR^!3vc3Xd_AuP&TR@g56SL9Y z^;=Vi)QL&1r;V$=o`H?cJPjGcc^5Gz(}Lv#9ry+0L2P$p6xkm1!Pa4#*g5@^iFq}z zrq9hRd9HRjqXd$ z=@>?$r+n2ccH`ogY*YzhT^EI}B+VNlFpH2N^sf9@bWHCDXO3t+h2? zpugi{Y8H_P!CBARF!I-g4uR{Qmb69ud*X=FVC`F@m+n?(a(l$=`&eoSffM;`Rw9&A zqo}{5(uKg?eMqOLvd11$#B-GTnz(QHj2D zL<^jtkG1`N#n{;Y2>R2wfW?FO_%tMKd0Ns=%6-SRY}MtgFX>454}-ekIEg=GDyfco zptIonFo~4ihs3%hda?bHs|)s%7m?hnrnZ7^ltwVZT$Ag2Uz26J_@Uh6Kc-KD8k$FY za+pK3ftz}MhvWfqO)hRG+l8Cb`6Xo?tCKpB2(81zWGzb=C9ZxZXx^axFM%LwUR`-z z;7^vd$>{j9VnJ0-ezq4ssnW$YxqQGXHTYZepsCX??TGD4!>W|HSw2wRjYn!)@!S>g=tJ6pgBKT|EVmOJ^d?N@UKWMHWTO1C8` zm4{~Y50kKMo9txwpxK(yEdzc<-)|lr(4)izuAeboEtT81XgfMq#2$wptwnOJ?I%V5 zD|isNf>}*6ye>bJz0VI{7vQ@w0&ZOQQ#-q{c`5hnFnBm15YwMK_#}hKZK!KeKE*|#+Zs!tLUFEtqo71Go z@AZ*^prmk_-D@d}XJYqUYFhd|_Anm39bZ)Tg6F3&^oQxp#Pn`yAAw6HJ~E2Paco&l zplR5$_#N z&y#hS++u5W|DN?`O2^3u(`B87_EIKVrP7_B>*Ad{)`!wz$yIe*=iM zoPrwjcEde53fI0S@`H3g`q1Ro{NQ2D6=AK~cm(jCev&&A(z6WGKuj&`2mB(E*YS-B~)dC1S?x_4MfeRMM;mt6V(s zTvJFDC8sSSy_I$Vsk?l`hc9lt=?TR#+rV8-E3uhWMhtOc#?}JU~G~B~xG5H5;wT?A6yDu9uptz^}R$C5Jy%-~~P;}1AcSk>uejuVld z3jq{l?ia1cchTM8#6pV)xbSsi2}tVW$Et_CK=4_|^0(ZCg9@4KD33w?`;URQT|Pv* zdaGJ#frrT`C+pH;RUA;+LnT?ZTwMA~?%PA6wuA;z$8$5PvV^0?X7CcIv!v+|TlCu4 zP0*o<3DddM{ZZ;mQFxW-W@o{Zz|K>VvIZVr%+L=1=kWe#GoM`Tdwrf{KwLCRC}ITx z#cZo3L{OiD$MiU9eFVjE+)$`ot=jbWg`CO2RY%sgo-dI)At(NOHw9sV=DYa)#?~A2 zHD0-5>T#UIjwZ-phkVUhuirK+2p>mFD9F#G=g&5U0!^&PtnphM~%#zWQyH%pIynv4*fC911eHkH4z`fPSYMZQcN-+)P0sjMOR+L zgAI$$W$xJ4oMv!dlfUamq*9i|^r2P;6b{dHuld=+xQ8_&%eliL1EK)b2S!8;ZL#lK(mR<{>Hj`>tLwu;q-OQ8_TaNI$P zMTnUqrJnMwC^}2srA5_>6(*n}2<+EZpMAD@#2cCmlJpF-Tm$Ye`b2IB)Y_5lm;Tz0 zE}9@^Cq?tlj-(zN|16?4C_757P2klK0l^C`ocrGNRg;N(fU<;kWrCw@vzG+!#>QSvL-~v968bF??D;{XFRPE3|%l2j+ZJ`0if<>U8-CV$(N= z_g+KBHhBMAV_Tb$uJ3_ZYheArw#bZ?tz}vvbN$}ee5(W8u6vFGv@* z8Q~|8cg!#ByXk{9$anjU!*hGdVSICi7 z5ZDxmmH#-owj#8GyQ6D)CI8I&DPII5m0T%-y)fZayQa>lPVQIbbCgF(Jj?#~>R)vE z+u|6~oVkwvZ2p*AcjGL^Psjt6C^U_qS$6(x@^!~RdAI-cw%qvS(ibgQnn$jPh%(T6 zvKK#|h=Im=JMsI!EcL_O#L~UDfR2}$n;9t3E1Ig_HM}n#|76_vr)8AZk)gQ}(|7xA@Cf*;--h;T6934+$|f`zdY%9*m}H@dqNxA|*65#tr@)5?-v=^m!x2|(pQDl+CaAygeKu;9=o-Su5s4Itl;mTgG1!=+yf!8YFV2SnBJ zuBX3@Nr*q_li1!(sY%uDDa@uztKB0^uxB8c@~_8|Y=LOf$0H$U?Nu9r>^r>rUqrWjT@mT~^&z{rj#2QKSqJTt7lQ=KSp%#?U~eA}Bh z?ZpWzsq9)!AFTR?VwlJ>jkeo1@HnsrM^DuhX~uWjSoP(d{*vq%2R2Aaku=*1`T2;> zJ$XwN7!Z&sAcnmNhOxHRJgNJ=R{d+1FLpvY*YPRXcvhda^sw|;(56A0sD%}Y&zajyQu<)$yQ`-6v%f6FcH{a)kL&nfddd4iAC#lys(_XWP8V zJ6qD=;29s?FZnn+$ED*>RaaajJ?m=o;OhK!CJj4c1?G1YTh17jAgMDYn|Mbyur7se zE%Dl`fj@|lBjceKw=CWfdv8W#<;L*-=5O5#+FLlixJNJUNyiLym6MKge~3@Ny0Oqf z37$>ue99*;d1m|5D5A)lm(jsF{EtQJw5kU^C^~Mtv=3`V+ewM`T0^&z4>}~%A#qPG zisqZ0$gRxPvc~$*vTSdDhY2}jPnD)#r@U1?huFDT%oIa5AKZfdfC19PZYw1Rd~kaQ z;#O5B6%Fc_*+fr-#uUBo(;I|r9EfV}Pz~(RS0(lK9kK}SoEI0i+5Eo7|5VSpVGEvQ@hrM> z#+giQTc$jEAzxsYB+uMT7i{>$eTc847A94*E~I{Rm`*OheNX|m(pO&?GGLW{Q~l{j zmuhhn>(Ro=g?ajNH4T>rAXo^P?X~tl+K>ONa*{>p=~7dyl#s&1$kwT{(zliFer=J0wrGk9gfyCz0+xgo*B_*43v8 z+vZ_wDu*8;M!ywp7@;37aHvhj9W5SH<+q=fO)IoKb;W~&b`#PYMe37YWZ$C)H(ZnV ztm~QI9ny)b1b?04obqHIe4X~`BNDGt<(PJ*aSuAkE+unC?WOd`&M-2y!xO^x=|_Zc zVcOwczo!rMHQ{2+dgqD1Me(6K7%hv4Cesq0YTO;15;~q%Ix#K8l4oLko9Jcc<;7)A zbg{YXeU~foojv(R%1PZq`B?>7w(B6&H(V;Lg|~Dny5jz}hmUptKHQr+ao~pgAaokp z@7H5gT(Kb}AWfV+C7(8$)GnLyx6-+1p`n=?zNg2CBvjk|1FBr&U5fIckm@h=TGKR^ z@HT@+Fvf!AR%iQHhZs_fbgK%}e1W)wdqxIc&Qp{{1L-FSC${+*1BMjVXRi+MTklVi z8w_tnS-@zRbkvO^`u{)w$%$LoTUu53HGYz2`|&}bt^}DJ6r$&M^@QX`0Y8?;rD!0{ zeSvZhS;*A~>ntWonvYZvhqGP9+|1PDN^qA`DdOX4`wS;?xp(p6mUz@UN(u zlb-qU$JO|kC{=muVa0%rhV%{Z-BZ0Dd0+-K-feNX_R1AWr-}`-Oo67S;imvhfmEg2 zc8`SwxRgm%A8&%6;;kN@@2d7Wqu>S8$jO3rnj|W@x)+>Mk_<^w-wEB1(oF->Mg5M_ zA>w%1t5}#I#Y|vorZb|{20}0R)nSsoBfn$bHnR|@gAu=~P`B@U&+|g#a#MZ#dpSb; z_r!o{Th%KVffWjJF0SCtL4iR9tAg4uK9?IX>6~jlH)PrSOOTI?>T>tAY|}=u+td40 zs*O8Xf2ax4ObG5XE}SjnY1W`+YBaQdHLX%ky1<}Ex)XouR=FuA_!ciRUw<@?e}NX_ zgxnc^1xblhLoL;et#BNA%w%ZSnYWsC%i>bg$C4`Ln@%!_8;i$L(^PzF+UBC)69uWN zO|?GNKI}dqC*tq7RBuAx?$~sX6+!z@$UhyUtKbKgA0{Ffx;pPaD33TBQekh%|7b4k z2toJ{EVC6G9sGDe>-t~1t7Cr>z~E!I1_HznTdlZ;`%7HhAp z#A(tyyPi^Y{jLjYuM@P<|9y6u=dCYHKpEY$lGlT|)4r@PKvFVw98XDWBX#7>+i#-J zRdx7`QUF9=Ci*}|n4L2yn=qfK{In19qwY<$ND)fYaNCplvuD_shJ>^vsWHQo7`WnO zjsI_bm-=m2tdOSsLMbg&&%ODAjM{_e)BdV93LC&c};IvLp0mieWFkizVk;<+->Q5!bQ9MgD7) zy1Rh{?n9Enm*YQ_RvjHN zxpfuAVv_KGX^2_aL!_!FoQaWJEvX}=mGWda5qwIa#(+%u+!7s#pH7?o`H|qERekob z-=x#8b))jfwc#2U2Lg6&pfk&8q-AH}W_MM&k~wK*9K3o(AokwO^&4GxN7Nx2zgU`8 z43G)4<%^PcwjbWj3*26gG@F8eqv{n@X=2KAEWA1z7bdsUbF zM=(XZBb)I#{3bbu*`5FT@V&mo*fMF!m@Q;kgGk+dhvqSBag zo~Gw@=e5bY3@>_Ruo?rL!@<|5dJngmOKZjz`cw3RMz-%1d~FHJK`&WPodJn+<12EC zNyh<CoW86Ru8Oo+MSyJhuoi!GV??Q4PlqmSQ2KNhAzp$&FtNHoIaDPC(;qj~} zRqts4#Uq2LASXCgthZZT5HD8jpyJ&5s**W$75o)JS#5V8VgtS0pxM?f+^=HorY4lu z-87RQ3jlYaKW*s;MwYC&C?*L2g6}$ipK_kdwl$5{{?$RiOrsgaE_i3pvpJ2)bf>>P zuU<*zAK3PPT4HYEV5_7!{y1j_Zzr>6Mlt1T&DnmA8hvke;+G~BO_0Y;=EFBPKcVJj znDL|*MyO&*jc(M2(nYy@$X5q9eVtq8{+tY0LinII-Jp@sq0yS70KS2~OQZO?aFEsu zA|%oEPF|Xb_`LHa0>c4vlf9x&g#-CDM{E{tv2_miwdq1X)*GaYj(K*00;B9$x59>J z2fSGOh64IuJ?n&J%%>=8gGaCJ>iyqll&0Ju-8m9wB}G%{aE8S2;7dgoR6#a)2$KUE za&%KuMNf;Fl+aS}^5k2>S;QAEFFWcKoAFf(UJEKcD=pB;47bLTd5LeNZ-8WOQ^b%h z8?kWWZ73CxHEz-_jfz6-j@3|q|4bUTva>c_RmiSi5p`hQzqF1Ln|Gh&aypD&IWA9UdEl>N&3S%GvKq5;s$S%Kt@DGA$J%#D@;ihc zWG^~~IxR{xeGd_OnLnhrJ^=-r8-tyMHG)%f($h?ZE0PY>X-^pi4wKnoVIOt1zZ$}b zZ>CdCaH;GRsvAbKQ({8lXZ?-+okoR0WgCb>x*yW7x%V^LdK*$0hZJ2@a4@c+oR+4( zVQbNZKTI~${o6TqdP9XjTFZMm78qLU{f_qzVWs83OzPpib%o`Q_p@ZOWO<)95ervvf`!MBZ5kf6hqOIgAOAt!we^Kba#U{j)?Zt2-nb}DJjpr zl7!{m>efIEU=-+6L=$7UF`LuX-A54(N~42ZPdf+`+V&H#vz_ns5r(K{CbYhmP8QI9 zyPW81Q&0(u#7tFCekt{x2SfPr%bA-#4@|jtO3V5ZbA`jxW7npp1#E}qYP!jpxDx^&g`7cCP|y? zZ&VG%?b&9%ZRfq_gBhG$Y=)uGkLL?ClhDVZW$Wj91kWYipSaD=`D<=|9q|zL#S`vSq1Obn^-Y7MkDLbgbggb^*op~);`#>Q2W*8_ zCy6o6QuXfrx6GvY72`ElQ;67JU+JBsZT5mz_t9E<6m?0@AhsE^A5Cyz)bl%ra4o_( z62F0b!p?WSxtJ0^T_;|y-;lY1EKbU>6fL_rdXMrL1Ym!D>QriMoW9;v({hIsl929# zk;TZSI&A9hxR|XMdQfh?HcedWw9Ki#@B@P7X5C+z(Y&H{S4vRPeMHw>e<{F%G$k*j z`=#3G(cZEm`0q{9BVf-8I5r`Q(^X0RuC`K@E5Kua#)?hncbJ&1zRsz5;c*g4d)+5~(xrErUR5za zRSWf7eJbSoJwE=}Rs}angSu2o5!oqhL!ZlqNlZu1+W(9h-7SWFJL0Sx?02SoqrowP zAwtunI#I8Hc<&UUs-JkB`E|^66&1&%f0`xk@!a?#(EAa;V6*;Ucl*o^4eG9W&RC2* z!A=3+Tu0udemix?asUH9~6C$6|)qUYoD7U&q|j17c7o zPCQw~mI7Oa>j(4f&3)l1QcB>h^Z9DgCuTNDkMy3a$FtGi*_q6TX83c!^*ao&vE(gW ztfQAjIPUMOw>R{`ggtUDtMu7C6^Fj_LT9MZCH0^YBazD->(BZs`g7mNq!Bq=M~FL) z0I`hs3YG1+!p7nm-NdvzDmPc4kYGyH#v3Awo|p0DeOlGZ`gBKfrxGrQm9t3h`)>ET zP@3Iy#pCkx(!UY`7GZV7y18Xv?KGg42y?{aC3Vh;G`-Y;Iq1f14yg$dH{ zmQP((6c#hkFV=@dbgu4|yZR}r0c^7|JH#X;F1q`eaN&k>~kfvL??&Rm5V z1PjlM@N@l^)M^LE5ec;(j4&}ipr`2g1Mab9pH;t{_80mfbTbo$XTz1UyFw1Q=$LN~ zFFJofbiw5FqK7gn=|kC9{1*Wyb~PhLqY^l$3+-)b@0ep^*dNZRxlpZeU-x3t)av}( zGcHrV3%C07F}<_xPs=eCNO{)EZ|bh99=1+hOVwH;q>tIaRYo39*Vq^J(7HvRaxKn* zb^wgSvTV4bOhTDcX^!jSjVx_xX+EboQL7uF?+b}zEw^}FRMM&S#EU7OF=AR~&mPYI z;la@kAWaZrYNYMuLSrLz*RzE+JjMS^1N>>!-J|vU=uw=b{d2mX)FVsCpe*AGGnb^O9Y~Tf%eU#T%QT(Bnk5^x(&5xeP7&-0=~w^E~omNy(||PdG-xLe)C%#PXik&f{TTUNdnNfn=^Y;ljlL9 zo}clACUP8#8kE_rNv?R1BB*ACNeOOdxKhcwb&7)PPN*<HP@3t_gMP)bojv3F4)eDZf3h2HvT>H zivk@(q$I@w++U{|%R=jV{TS6GM;D58+NNugNj-bCLBh=j~o7V zJPOjnz&?HCp_yj0W(3Tn^AT)Ku6|kES0)sHh!uQV+W0$C%tKZ~8Ac+5g|jTm*8Hf| zBz>%9IWjf9Cu|zft#1u^oZZ}Hrn^C-6>bqJ<~rs>HN?QoNClmvp#5>2l!QL5W_SEP zhSKiqZLqU z3e?AqSBD-&P0XNKb_wRT7f3$YOhFDFWxv6%3}-9!fQ*Qsiio@h8TTGwj*X61wm=YH zYGXmkf-ZW)iY(_HPimO%sE_)s+Jzp&WpwWY-Ng;ctbR27LIO-{JPdr0D4Wb^TS0 zmLNp=N8i1SganBeXw~uKu;-AOLjCMfZ~1PzFl+3Gq2>3}hu9XuA19M`jhCGvSCvSy zsdyf1M=tK^^q;;3b%lI>C`6gSKFwu*({i0n$and?sp?~|JC~JV4uobNP9DFbJkC+F zUg=zH?}q`m(pvfBJ;0uP)C?ZeaV74wbyj!1jT>XtQ(JPJMK^mwu~pv$*dqN;E%>4^ z1hmes3y@AphHuy^;b|U?YKiSs-(u{nkN=P4VW-|W>tvEF3ogbv6J`;i| zDN9>u{KG!|=MWKd)n5Xp<7dl)nX1)?>ijS4^vpN=`Ug-&mPbcv0opy;+i(LcE&ZSu zPNV!>Z`^we!{|EDLHY}-+h%%Uz3{UbrGUt72O zBK%wl;`OF$eum?NGQw;!I>qe_gZ84R(wIPZV~#RnZuAt*oH4mS9qR}#@BAuhrdr<= z-x_PI`EE9fip2_2&&~Z2@FE3oDkAJ3+{t(?Dmn=68K|XD=^a+U z{MKz+6CxDj^1-2{y7^*Y<)_;{2fW0ALHDl==_m`_C~Ckj=FsUB|LuSGCId>t?&uD~2t0b9s?> zc8&RnHNolS@8=@F+jpawd0o_KEfbD7%*D7Y@h*%?q+TD#*X#R^G}DCzr%KP0T<3P7kHd6RUSCJaO%LRaUQb6S zg*oh;1RHwLYrpjLxSN@M3*iPyz*WrBzp7JREb#_P9}hWvFD|1yo+=uCK$qt1v*TD#?uB*hfpvMyaq7pgmxpBYUJAhseeOqTliR=5i8q>hc&6NA2#$_Yb@iLP z$#-0lCKS|fWJHamCEg4+&Ie~ava8$K64ViAQ2i&}{%<3$n@<3Na&4~gl$Z$w|FHG9 z=mn3NneB-?##)&t?4=p0bro3C? zuu5hC__?rt5HLS09(+JDI`AdE;prc>z6ufU&GxF~N!@&&+5Q`%-^IKeL5Dg61$Xyw zuZ8l6@a7eD5RM-{m>K4|clp71)x@%3VtkkYsJE7^inKKWdGr&`?x&UNat^Q33i&n3 zmI}ka^#7^J`%^-@{*YaVi1;<;DNlmRi230Niv+a@ts@}5b(BR;N^9S zF0s~~g(Z?})8^g%(b58cG#qAR(y_KzYJ%sUWxRnZtk_a$;Bi{mWto7b z1fJd0P{_#_y4W4JFN)SCdg+d$lNS z%Emqm^B@HB^|kN`)5q0GNeYRep8)7|5duV>1^tmHu~jKw1m!gyf#=~Rg_jk`^pzBM zHq#lN{R&cEf@-R-@JgMML#{&Df#WFheh_TPQCb$2ZbHXEQFUgi^A!xU{mlmGx{wo$ zpLz0da8j?^BFIcW$~(q#;YfrR4r1)E=`LJA&7IDifa|e{xJcsV()h?6r}qx1J2j>P zy48{bg+j_=Z#@NGdr>`eoJM2=46QQi(#k8FQ?YoFw~57kC3aWBC-^NS4yugJ%T z*gGAl1Rp)ExYvd3<7undzI}@jMV>tQ&^Mm9oLI%sz{DB5CP+5uBD+p&Li>og6cur0 z4j{z5t$PuX@(Pq}#4pmAqjdF_`hTqb{!b1pJ|?FuDVakeDezjaxQF)uX8MGCN5I=8 zGwdz1A1B)3+o>|>MQ840txM*R*uiNe#Z4Oew(_Bc$4sNQrRcdCgx`eMJL6d;DVnfx zHxWHYsOjUo2#KPyMfMeVRFAKEPft{EqAMJzn)%W~Zq(?-Ltj*hN^>RJ*_8?n3vZlF zWtP`@=;YB|2cy_y6w-b!z7iU&<+qD1&FlQ~+WO(pK-L;Rce!PjJ6Yx#U48#6OzleeBe1V*doM_>7~f z$MvzHS44{gQIH+SRf%hzna(H*RF zcI~qFS#EOQmAZHWF#AjJM1VSpSY-EJd>7zat%sM;L02ovozAw&oTxB*rs>D?A+;!u z$Wm`T+8@mFPpu1IOhJ*8R&UU}8V%|qO#&N&DLqpJkUc@d$(p8?Hi$x?n3yu)aYAUs zjHwvNsq`?mwBb8K<)ik(e_7z{V^QYKX()56u3!|YM_mXBirstS=uBxe6k{a)Oo}7;P9pE-qeRepC?scppgTz)M@1Jqd1BN6b=Jl}d6~S8YBuaZ*W4DEBePLS#b=Mk>AqkFu-o9S3 zm-CvWu@SLYmD@AdZ}Da~nb9-9qZ%_|Ri5??KO!{e#6AVCCcxac9kU9YHEn#K$NdtU z>AJzPy0OX&*Cw9LXE^{?rcLs^%Bf`|;(a=oWbS{O1br>l)Y?xombC|{1$$H8Qw$|# zxq3w#hP%|gRk%?R6|dp(eQ`gDPgot|7*!9r76lPrPZ7K)AUfgl1<{M%_Ig6Vwl-nkcU;qG2ze8oc$19(oSNmaIiDku_6ZK;~Bd zNu&QqKsN3{s_3#|a_Ud4!B>e(??M$Was}=J#3pxOU2?`c&n*i?s%6s*n6RMDBHbs) zHzS+P%oJ5VJR(7R9;3#-rgMJD5e7#xr&pbe#DTyVYnt z#7}T=(WDbA&`_Rm1~qjGp)|9Nm=?>-1#;v86}EVJ94 z9=;}h|H9EPPU`q+Rx${3ANOcmT8CGQYO1I6v6~jH#;Jdm&*w&tt6^*@cTi*!0p9 z&0%gE!k;v3@)^&5|P4YtWl{eGXgz*F0x26WGmBYo{Lu^Q)A{{@#mSq~u zagdQ6CX0F`&PtDUS0vZWcwjfLW=#nFG+0+9Dg$7vIhfz(Ja%uLW{d6Llk5DkLDcF3 z2M#JnS}6qB$Y+(Oc%)=Z`ZJ9xKU}e(1@9?EG_v5?5W2q7!dM$TbD%Mj3aU!rA8oXf zYAG>ih)#Kmxd{0w301df;gcpE-Z5J_3B6K0XiIvO%qlY%Fx0HDBB(6S0+L8Fw&*OZ$itVLNw3phSR4uUok97NM~iRm?OH?WwUvj_r_!+!(uH{1jV}t!bw5>S z*hrZ!`_rYw5ixx)PBYWOPu{5amsi+5M@R_?kqPqOwRvyUFJV`s#@>_~4-fs2zrq0o zf98ZS-!ylvk2B9$io~!+2kdCw9K7@JFDPU%u`i|$|#>aoyh5r<@qz6q?4dvXL8lHDU*> z90zC;_#FS_rk!}=C%gjhur{y*vENU--z~*-WA?{QD{jo8(VO{A4y%Ik3EQ;FPNcIO zyoj*iZQH6*+JJdI!$`L-eXz?~%#aYAPHMx7z+zk86gPSEQtmQH|1^S6OH39J9>6#V zU)}L+@Z?=|&-j*q+ppA$D#+G`=ejpD>CWc(Gi=^yo!QTd@brU(BZ_dV4Tg?cfz8qx zzDpwLN=$?~?j0+JkW682Y3`$bGXtj_K<+3-D_$ieV*OXO_EZ2h%~87W49A6|V|+Sf zmCzJiPslCgx1d-Dlj>H(3YIU_EO^rS4i#$iJ7Ud8b=sZ69j7M^@skN{R#zk~^c1^B z5)|tUHQHOXL6!Nh)w9+dCP*>I>Bgc&49_Lvl4XY(deZ&-UXtG2OIbbBm@B)+d~+Sg z<)v6%y~{270z=nK&m0_%&(3$JLQ^~_R!J~UC$aQIRWa)~P9*qLE_KOL(h(Gqs({ja73oC;=^)YtRLYC@ z{`Nh4-*eBNvuF3+y?ghUf1cz#lP5FZ`Q%$>zBAK3zUw_uCP6WUy-6$igBxNs{Uj)H ziPwL1p=$x!Z=d{X(rjkfI4DM}xY})M0pk0Zt5<7o{d;g*%BRnlcAM4HwEOT+L)dN` zZmxma|9)H}dX+O`A^m**LmfcGcj_mDf8_d7>Q()9@-|23BYWl1dB?&tTxNmXK$o|h z#~h!IQhw8`)hA~;GbGx+PnhG*>0$2P{V_KNHut2to(^IVN!2h9d^$5ffAHPxny6PW` zs=5Kra3%fIFLx`^llh(%Ke&Tb&#EP)_n>_Z4mlRj=t}adSZbIpG%+Tp9~4hFzqIRa zetXoEUYFWt9UO5K!0j?%_h@Ax__Ea3^i%%GcT=TF*-wRJE!Y}V1Ql}!U;TRVc(m>2 zwzg#)UVgh!iP1A}DZne1I(_kprv5T(Xjg;jT&FAGD*|U+nid7r6Xtgwryq)|i^@=b z2}Z~cn2Pjzg?8hJ#Se# zASx{OR@}s>D*V99rRZ4jp*kxzO$9^l!-obE_hLtA%dZSIaBZac?=_E(3%)Jr(JrH& zn(FWhjFJ0jo)vlk_{g&D#gBj(KGw%qnYF}gd;Fs#|LqL4m`oPxmsr?tj@)#j_Q^D# z2t2Rztb4x_F*d4hU?P~)ci92NsFr+vIcaC-q3xc_w9Wy?m0ZY zSl_2f#P-301&F(q)AAx+!8MO&S^gcqV+H zchIKju4u<~o}=XdD01|Ic3}ixVb0(?&kL)Q7dAxv-EA2x#qY8%iP9Q-Dt_N15!F?z z^bREA7vQ!v<*5zh{A?{(!0oM(>^jTi2MHWl2>bB0uCR#1Tqu`Nx9ZK1$lR~&BVO%S ze{^+b7!2OB#rD4TK!czBPDZFVZC2FWt18vjEX!RDavAe#D)7r4aQ6K7hkCu9dGLt4 zCH&;B^>`aJQP ztZhl$0NisrZ6CWQvj#(W+8S5NTUpyP4mLJ6EOo-;76K)4z%{Ic1&)Hv0lM5K=XVP8 zKG6)w=1Z5}k6mt1#EZf-*MH0>K| zzeXfRtIg9E1RLzgUc59PPm$oY5YY9R3)C)&7PlTX7vKnzx-5%(B;k9vjm>+y^`PS3 zqVVKm-tUQCi(q$NnLsg}+s~J;JQUxiZu{H1Sl%xAxlPF)$|P@c+4g@vX$cN-t&G3c zy~yCfYP#sTRPec~%Op8#7m6{nS<(XM`QQAj z@{F7?NtkVCwmW8p&a@RyWNaG*AxReM@)yJ=yx{!e&)(6(g|-fiXYnW2#LY=kSowC3 z_YuMWDZQX<{gYakot-O0A)TB6*5g18P7H~J8DX~)>g!B+P-75MA9)0ShJ7qjWnkeP zpbI*WC4Pk;NLN}=y0WM1Xt6@L+Dj&wP}##ef0+5^wBv@MGeS&9Nw+&eiadDG(fhDB zMonRAmDKe@W9BzHBoW<3`?9#gs{j8K@%;DI^DlDc7+@doeYj~Sg6^B~p$!!)c!2vW z{M$OlKk^ni>7@U@C;n$FOMx5yE( zf>-_(xhL5FMDAa@Txdg5_fURSCTCz^AOoiVj>sKxoX^SXtwH&zu261axSEJn{ zXm7Qg-hWm6|I)Wm3*uj*XdOI!kf`kA z)y$<&a6O@nmNav=(y4G(L)NWk9zVImpK>(_TkNDf!8&}yT=<*M^kGJ+W}7Y=^haV! zL5C^#SuU^0X|UjBHYD(fI^wdj7M2?IIw$FoN}G2E-X`t1Y<2fOeU>ZyPa*z)MGizn zAQmnjfMhBvKXf2shv*7_C9qoWh=S(cBA4}_qM`pojli#tADQZG%I?^h$z|kK5;PH9 zbg&q%E}L|3l6AkMZSyAV>AD$T5E_zcsAv5OKl3zFfz%sK18wpU45i zys9Otg|E16-Y9G_Tc0Wz}FO!B6Q-{B!QyJ&CcRV|xS35!EC%2#1Imr{)Bw zkZfLDidP>AM6e--$nkN_!%+8N6Nai4+0q}>nGIQSedai^zO~tt=qg7>T36`4Q;lF# zjEzYhZYXAxmrh>_!|>6r{KtUge{L=QVOWoDeL3+?1pRIJ;BXs0e7W`I)*u+)TVE~? z{m<|LfaqJSvPSyR=q_u6t;>nT*Valb!+<;TVe-QKYQ7-y3MAZ0-vD#Ji7hrudMz;_ zAeb0vAtEIy3e>29TNENa!D8t-%-sA)2kJ-t5iOIz8x#C5blXE=&x@${NYDb-k)bcM zgyP=YbXhuKj9*9Y6MEaB{^xe|f1IcN1G(eB$ORzii*Av-4Ikpz|7y8gUvBYM%O(HE zsqO!+n(!f#hUtuvJ{vFf6jwOcX>?(b3H(}znLagB%Wy7in#!IiB@s$(tOQv$0Hclm zK>5VgHa&_6OI}OBAg@PL5gV&e-In1F9*e9FzN<4Qp6Mp5ss^~HKkAh{ga#XxHXzMM zt{hxWIX9(CUfc1%tCjzYe)k{9&EozGax|f${}QocIB&y;bNzxli@2|?73Imi&OV{|%tGA; zgjK_?+CK3WIqX`gh0XTANAi`O>6t0%I1qewlzEGe95s#EL9Sxh6GmilEczR%sd?wx zx2LY%Y|Pm1Zcqyb&oc{`)U;-}a+vTAcHP`ueY&~1Txk9DgU#G-PjvB4`_0W^j*?nr z7?y|p4!&XEh6oLM03@$#8>PO^-Z5cjX#7-{nlLOBQ05Yto{(bcTHrvwv+3<=WRT+^ z5}2^IQFE3KE$%`eN)h9VcYk*N+(+8?f={tgT5BW(Z0epB@GO>6!1Xli;758!KSRQi zcB=RBu;bOVq*iIo8WF;UherBHt|~H))&D7{nQ|iXR7Gc_($r&<5}CXzk&ucvb$jbu z_lWdq!Oaz&6?11{;Zx3kMGjjwSON^y@T?%pL|VZf-LJjEqlf~)U)zi1YYqX^+r^N8 z9#omi|Fk?rQmfl*GU-e6K$3!sM%1%Sr~~#9T}Nl%ni<~DT^wPzkL%DdbI)%!yT7`0 zH94itNRNxw2$8!#H$8p~+D#TsI(~gnksb{p35+H;9T^jO4v8daOUk~#ZX&K83la99 zPUo&=VSl_jI)u#kN_)L8Cf6~}K{O^2ZD^62l1fBeNKxT4|3?)ez=2jYU^w25MaGC2R!6RDv53ZB&E z_Q}lbbkfDQKvIz%yovOo!sR4Ghz9HquZWM_#hDcxoOO{*ahlv~p%3O#wOZZvdFl2H^!M;Y z*9CTWV81`~F1$6!=rf{E-Mar`j-fBisi`13-9VnfmyZ6SRFDuWwxTo=`Q{}ZZE3GY2Gc; z-eF@a_+(ONdRKYT`Isqz zh?BZz64RCC0D}}#MH6?p#$`UM&JnqOeX!uKZe1+h6I^});Iag=GlDvqliX(+l zd%3PM(?`Ul!l6E1bHTxdOOPVE*Ez+hF?gn3L-(?2h?P0F-Ak(kK zl#J92V2;3v`1S1ON{!Yt;uAA^PRS6V7+*=w(9;7} zB1~7UU8`36Xt<-ug!0sFqO%NFq4*9G*w{E!mT8)5z2OO7oVD}V>MEopT*?sa`Mwe+ z!ZNNjUDefYY)I7ly_+%SZVPmCuW9FuK>M{4j>*lNPa4EOqM%YWCyV+*y#8A^Y%w&ha}_TOf;dhA+ZOK6svH1GPN8e>q+vV;}gl< z@@RuZa3io2suYLlKtQc);c zERVNSqd%$J=NSEZeGIQE?%umk-42-8kvemMo7$sd`n42 zr29v)8_>na!-&IxzsM0`AZmA2!E-d$fosi7eqePH+iX@Hw3FOn66^FA0?y7AcY`0@ zNU9%Kp-SZrY7)EsvClkMGC=epfz;JFvvKu5*1r0 z$(xZLa;J39utCxd1Jq}8XQymq>3SE2#?QE;19=4tN(KA&bU9|dIf9?5G%>CNy3!V& zG*hoYf-;3eR;zNV5yD&1sIbl=q3^F!ipE2GKWWkRs0tb6t=&aGcKK>aKYUoJT&i6P zaMj#YN-yij9ZFV>Um|9eZ?QRFB})x?eObjl9cA!`f$xCmUwU?Tq{q zgTChI`hI<4-e>Q7`11p7VLav=Gr&on7MqVTPZ_45X<)d6VD%cpBXt3#6JhbRsADunfVvdiqFbdLfGAc7cb4^@N{sk#8Y@E_N0WipuA4 zmFGO(Be@F6wquiSTd()v$u4^OG-bLni4UX1gJ8kp{Z`NPQ(C$RR*Wr&97JC*W3XLR zk4;KrKXG;{jM4C^7XL*~003{@Q6rnD?ub+6A`V!y(91ev06{_B&{QC`bs<6@=p$ zQBVLW2u4aU>n0r$h z8Fz*=zx(I`7@CdKXNLtZ&$DZ>`(g|gETXZRqO=E4{^&Lwc03Fx8UU0bs7KhKV$~=` zZZ?x3hREHX!tB|FrQD32`pplC*HTwj3GG4pvR)UKoZgjD&3(@{CN9jWtIX5szBF0B z=XC9BVTv3r)X!Ak7fx5Nl6*%&>G8Qdja%F2gzTrP9nF-C-kY>X&;1o8ov7S5HPNw7 zGzOa{z2r7{B;_<9Ycw3E4}Zni2M@ra?n6&PA`~Eza@-&0PWg>3NixDp2CY^lNk4ppojtYt0B3?BWr5BWp1YO}FQlna8s*zk z#PImV&@C29haE1ysZTN*Y!cp>Pa=7PZa9$Joxu4XdKKS#Yn7)y8b}>;$u{{XMU3r- zo?m`yZax>ovUjWAgjH%n$x{vUmK|1N)aV9owVeLbN?ZY-H475&b`E}9r_i&E4eBrPNSHJ` zXyW=wBo-!YReTnXpFO=FSmqZS_~1{UOKwQsmU$n(=Dwm&aV=R$MvIE|g1hy+;YX$d z$qlY2tj#P!PlN>Te&cTEQ5#D>wQHahJIEyDv_Pf0$bTi4@A_u3lX(W0eizlJx@C~u zUIhSX<0K(N@l;CPA3~vU8)c>vflw$57T|_d#n!E1!~y0pEBJYBc-0T0APHJ*Q4K00 zztdHgOz{%-_R4*8g8Vm@etJ)AxBuWR)1jeTN|PAc{oGM61`a(^ zN=Oi_-Au8XU^VQm)oA3sKlD&My}H--{AZkiFrYD%u5;YZ!NO&HzUISdXZ|U54y>U!M)@D?H{SO1FKfTsl!$-f zv0q1%aYOPn&~8%b3BjZg6C_L4lsj2mbEW8g#8}z$%GhWvGI3F_dW*c3^U8~*3?7by z>)7Cpm~AZhnQ~UgQNAWpwe%Cp9**jA?{tK!q0k+wpmE0z7#1}`7?8g!u1*M%Q878M zA377yevvhEVtHUbbNAyPgRSqAqS6x-QhhJqT77Mh=U~&9|5nSjzR;zN8OdqBQeKQ! ztIqu3CcVt^W+l>{>NCbi+^s_Nhs}Iv@tCz?iUVP!GtyZ#UycIb{JE}!`Q1L;5gMyH zIr~RlU#!MRqHsIpQCNLgC_-)kg~3t)a3ajzUohaF1^0(w;e_4nFT}Vm2fd^-l1Cbu z^)bJ(*Lu5O?d)3}0+VCx&j&~HFMNZZqi%(J(b zEJJO-|7?$iif9VQeCaHch{qzd?M5)had!@M1{25O166z=cAOPc$C9iSjp*i)R+Q1O z;8UwCrp>a~gyZHj<^z0gJ(VTXI{`Tv4p{{|qS3Dg5(myG`nfKyn8fonzVca}X})J& zl`1QLwz$Xf&6$q6b@_3@!Sa!;&2=BjetG;y@Yju(Vq$KWeajaw*!Hp`8O_2E&$Gk} z1+u?A%`yRpMsNyHafTL!5)(%&NTEfVL5QiO!DUlp^QBUOY*y(almc=Dw<~+othdJt z-(2ucNeQPq4U0igZ^@Lz#mBf|@}(WZ#CjDC;NW8vs+Q_{>vg4Dh;QlSy%WFl8@Y*_ z=$q`f2iH>{>aT7D-rwkuo}q*~xq$kftX{Z4F)Ee{17(V+aLVEcr4olnVh|k=tZoc} z9h*%kR=B&CS^mhZM|VcX#&$1Ysq#il!H2|Z@)Ue6<}6WE zbwWi_)2x+}d3{~UGQ{)PA^YY}4G@ZfBwDC~M`%6Mcw&Hn@JD)dxvV<2PV%bMwsKqX z9ui>r^0u?0X8YhTcVuS{_ckx1I~Kn)<)r_**_S^9vq_{_8e_m5J@*Ax5cM*UQUNjQ zdTNXtk{SpH5bO*%EC4`-=>TVdtjVR3g*!Ml7^hw|r`&Bt1kU?gg z zLa|e2*E8IY%8hb0d-W}^T}0X@swcrwwE)V!=4oR=SPotxM#HfcQI}{qxDN{Op+%ux z9m?7;aSqM2Z|R2L^`!&~XZT)ko-OfLG|xOOw2pI(Q&v^>nQ>XjKX%?-e^^UBsiAt4 zt|DMzGclng7VOvQ`>cc{_Fm$>_fYfEdS^lE?3wR4UeNZ{hj*s&oF#=H96M zntPksekSA~QG#PkAyg_5s#`A*2r2>9h$AIIs|aEks2&86BH;iw+M7Ou9YEf>V?^A0 zyB9Iv;%Ta-RqTf-=~r(Vz3j{$n;_jj%!s{<4W*0r>zQ2O{%RM=d~uMdS453HO6w(} zB6)W|Na-<)43W_B$PMpMaO-rEMmNoDr)wO}yt=`_@X*rb4fN?|%7lwuoASLe$#)`| z9M^3&e57BI_jOPBnZxiPPB9&LRM?>&p*1ja0C307f&fTyEQ%fhY6mnS{(3({(3fPt zl&J}OAHvU@an*QYO6Rn}1fv;rgPyKKh`MfV>rgQIrD{fRrRsT9F{iNLyQ=2o+I-0; zhY>x_$20R^12Sc}9~343iz^omK&E_E!Ut$QGlsc<0PrINf}aQ?gj<&pW9eVwMs)

9+GI1 zgCaWIp%C;_^dnFo7-g&p#vf^{bA^O46*dghA)m~xt58ked5IG{#q3tqph6}l8vfQ9 zrZI2_42Or}VbLUuA)s?z^)2k%X$*mD|CB>e}W-DNdzhMzf3dp07Vf_~jl1 z*HK(u>6G`(<^(9Tf62)gSg#1i`Ya=S$O+a@0U>2zskkc%zBm}50g8oYM-u{^ zd~d$fXQr|T62X0!%NGug@3T;Sq}qN_E?arBdHpMW*KH`3*`;d@Sr`S|pRP9kSRIS| zWjgxB64ffTpZ7wss?D_)lL71f<2MRSiGogYn~^PBy=z~-xoefz^;L$jnJ2yc_@k^` z(dfQ`P-*{jCN;9K$K80^WNSI$-)B;Blu~J+s>MmVU=YfktY_qXYQsnIqo0JyKcbJm z8OX9|j@@SRv7hBpBTOT$a@WGGqu!c<=kV)v4DTb@dWtqJQ**JF?O>WpHRR9ov$QJW zc(kWI**_1gD9P3dXMOcF{A*V+03cT|rcqH-HDZd?bg5j5{3 zsg|wa>Tc0uYC%hnm}=htIY~XQXJm5_1}GFBGtUYK@pelg^SYjI-)++wbpBAn!E@GUll`vkzKC>mWaRApK|9&l0%v_x zkN(vaH)*oA>$OJn`ET{7f=!P^Z)Sd%C7!63y|TY#mTvzwky_>ulsxKIrOG#%dG_;e zRl3930k*)po`H0zuxK22ssd@2fF7g-v_go~!?FPoAS5pM))~Y`VuK+352`9>Sh?(z z1vltNyvf#sT&2_+iGr%kp7ttl1i!ml>(v13n=xfCe<#Q;^a;|UmBs$Nl~Q8xKu@!o zKQw!wU(M^aw)+>eoyP?(xn!T>BC|hMc;{8?#e0iAv>VdGm2^YV`ZT)ixY@qG_uijV zpj`uuYfC76NZ75S+b=+mO@@UQLn?subfMZO019yabR{+!x{qWbdrmY&x0b_L#81chv)%cjH{XREdz3x*l&7OnsPsg(Xwk zjyS;lM6eDmD zc_%Nl4@FJ531A>!n|*hO#zdyco0BYJddkzB9O!~s);Gl$W(_r&E4`%-vR}_pFLNe1 z!B$w<35%I#<6@r_J&;=3zEdUaz&(>?-FcSV7M|Vt&`VapvQN(H6o1|BE%b`%@k_&S zstO%#dm7>#EjU8B1V`mBaGsttbHez?ZfH7c$jx%);{RLIA4hI%bB@?c{8F9Uaa#XeOvz6IN+bEeqnSH41f}cLP#wrW86M zHT8yKj~cnRwD(nrHD2$8;cKd;Al^&GM7YZ@)>dxd2w4(UPsiXcI^fXCjGW7s=}rC6 ze=o!}nefQH^^!V{bSb-yx~QD=G#eJ`5d$NKU^yBBZ~z7GP{maT?lRvmf!^-npjMS7 z=s4CC`lz&d?+n0&j-g+8P)G zW;b8OP=uMuCgkOJiKYRqT-t}6=#LkM)hcR%8?%vGj8a6rLu|8rlySj__5?dMHs~m* zXx9+>t9V!k>4Q!b6FC+b3xTI1ssm>L!kEDx_NpdvW@W5F(>;paHEpAQ1vsR2u``CPIj zG_7zfmJ`MBS#>x;>8j+N3PrF{vq(~CHy+|4atliVjG@8CY7QNMPGnXz|3HG}U{3(K z&~A~HD+QT`E2yS+{q|?JOgW!BS=#b~H=HQcDpIJ%Fx8-}7MOD$Z} zik_Fd!DyG}p^}AmIFaC4JJ_p41h;d@ByImu~xC(n;J2c&BRVz(nQ2r6WWiVcC@ zDUu(rLd*=39J)7~J7?&`Y$GdF;(B;QRXj^;M%r~s{l$3!!e7>3AZ zX^w2vWC7{QOpwYe-psc7?DM=c83OYu-)7o9H=r{zmYzy4kr=`b?*mjjKJsglgco*g zi)!-9HFX&hk8p!b;k4!OLTQ~HlHRcHJLRr|=Wn_V#El4Cn`z%ZZjVgx%DeU=+Kdzk z>4~z8Z>=4j$#lAsZDbp$=K;wMWjs=}FYSV?L=($)ZvumKX1wIAlP7%4pvoPnMfY>i zYpu#4=c&cN$i)GW=iF)v3pD7|EnKD`77Nn7cy^;50P6{UX;do)^p>FS8eB<@*HXrGxoX2Lu!1{mB}X1YFr6@|5n_;TcLeJQEr>OX4urZuM}t@j0gM3% zO{jW?Bto@@LEySV3P?_7Zd5cVSzIAa-)u|J23t_0cyD`l=4adcN<6411OVz$P(pI@ zIAM`W!LU*!1L0Kk7qXM-OM7%IQLrCKn0bZqh3^P ziMtgc7gQir9WBbRDJ1WY#xR!Sc2rsIlW!6N3B1Hm+}lBzLeN?v?7ji0keI(Efy4tw zQA9!a(!)>Ls=~TK?`%D#HE+Yz;z#a7-+j)=S9l|2Kpq(IMDR{AZ@MGX3! zUhhSa_Xv3Ug?h(q>~G`0-5tESKCkZ|ewgJqy34umQoVfR^1b&A%6P-ZC-l32;_r;P z6ze;evfGs=zw6LQD?=g_16Y6rNY#Y%MSz(JJ36d%g|~U(fs`OLjDWP;7`i~~9;Zx| zozm@iDF)f|N-`aH&zAV0V-}}hXP;gehAYt+_*#o&s&pz$bJyHiI+ZJ}I%RB!ee#vL z=C+{A8+F^)zDr4Zvj(N*trJ>q`uRMThGgbyeA8UFGe7_Kbz3)(1KXGuYdQuvcP~VG zNE5*rz;}rND|;xJ3@9;b1x=hqV@L>Wfcr4z1$+U*gHiL-gw9F0p*o+QOw1?Hm=kG!&8w^kWA{U8)Y$s{Y7!?99 zCU6&q8UU(mIc_s204$)&rg9enc&AzK%0YDAV1G@~{LnVwSQ`Gue6}tll`A8?YUKA5 zwcDxXfAhNJ^vOa2Yp(2Iksd&e*cs7`W)gP{w|SE3 zS@4RgNoWKygwhZSL+JvcHRt{Q@!e32IED^~la_d#k`$((ge8m(l~@n;u5ji}<={&n zC01M#et@>kIMYwEd*l4Zh_)^M*HUU_PnL5jK+Gyoa*oG*YQ5w)sfCd4}Vc2 za0niZQ%ABOJhEs)A zw@N;5Sl=22CL8%U8GIdUeG%YuuWr10bXw=;{&w}7B|poXGjF5mh1hhHYrJYO5fRzX z#Wn58?m$%q)roGtq*NiDHA;Ceo^UWd#D)m91^}K+JspK4OiEGM$j2zoF8JLJDs)4{ z9KTJ22QfT`{|XsP6>cG%M4}u6atZ)0iOE}0LjPu?EG$9 zxW8iT)RsQ3FpNq@ws&-4+3pFij2WP(J={m?#rs$H22$p_#|`^Yj=iYDE_dR((k@jh zz*HacEls9Sulu3^?5eDg#%?>ITA0kNsoJ8gMn-00j(fWYmC=)=Ant)hOR^XAGS z9Q~9^HLhF-2{(0n<(HKslUkx~IQkBTVX68~)P{u8-`j7NcPDS`z~4e02Xh`I2*h6q zQF~zqQ#qTc(qebQb*-qH!)g;t3Q`&u8v0i^T!++P_G0lN>j2luCG;&*a z5;FtV9v~6?=p@e0GC0lzNsgN1D`JTl;=+m|)X*1?urNR?wyowZSU)qw#OB=cat_-J z3|jKq)D3sl6)GAf%NP!Ss#0$n;ib~6z^R-{)I){?)NsPw<$(5A6_CPOr*%Ko^t6YK zs~uDnbb7TDpJY6!jViUt?k%)uh(6IeKRrpBx7y5M(J{?4z6!ZabejnFyj~;6C-JfG zP{~+(`qtCib!TqDng6_HxzJ2)_VsC)C4lWCwZ1Ci{Yjknwb<=n&ec*lT1F^BUXWZ5 zImIs=35ft_QT{%HCJ7sr5$PJ8rnvTYI2n5VMa~j{;EE2Bwa~1D*){9?v1k$%zUI{4 zb_x?^wjKNu@1AIxz0GG}K5c0miMlc%#&DSbYw&cYHde)lp@-%q#9zuLD|ZB1*x zy)MK~;#S$}%yV-${7Mkl9zw@D1gkH6!E-2QX_&YFey`(pzZe8L_f6)cQ~U zu6hZVji2Qq%(|M&&Xz#Gn_2nq_VipCeac~6-IkD?fzQ$}*dQ+I**6p*GjsA`Wl8&i zpd{hO>LX8{%rS>V90iUIge~(K+r%vCbI#YIynb)YBl*6Q8@2CJftsBz*z`I*xhjr# zLSJXsyUNA%!?;Jq3gY%`@X*TeF*2#naTwu@ja*9yqEueL!kDF$WiMM*FN!e%B-SLpbhzhO==q`qUBAIS*uu$e;!tLdFl~iJ68rg4RYAly@ zPRej|kHwE`HfcR&CU)ZLm38G;*QO8VarqY`rMWzYsvP^AT7 zck#sPnq!jcDsn!J;B@ zD=oqCQ-;)0Quv7DZK@2SMweG3be^`BE6^I7LhfD>COO_pKh#m=CH-1nuyg-yRU{M5 z_uJ{uv!a9)X$&Z zFO1ILQC)qQ_FLp_E8O10V-#F2dGe?I`_)azpT&YKs4npC*qb`V&4StE+wZ5}etYQx zkFqP!fV@lDi(Y$J3s|$JTNEQM5XS(OqN8CBjR}sE|0XQEu479)epoGe>$9@tCP@j1GeJOQ zza)Ug2tp(s-Q3EN{x_>MrDyebj4tE#wM@d$j4@hI&HEXz=j%A-XzSS)qXMO$W-`Fpy}fL9$!q;&FZn_<=1* zYiyWr4J#fxb6xHSfk0r1LPZTw^d)YYAPO-OZ3@|v!#T}y5 zUbxNCSqc@9t9-{e5od$Dg^3o^ z_Y<&cvuAis(kA9ocR0Mt%LrG=Pr9uwWyh!YmuVyOO@e8CwKN~hewD3x4jw7x zH#?n7P##~Vz(M%))7|7c74IDM?}gMhzrMa_u@yUq1Qfg|J}V)=;Y|u)B?%2t+Azxn zAh*E^6z=3Ap5S^cL1HVGm7Nn7S_NXg74hUtl$9Svv-q=YmOJ`^N6ckiTof$RJOu31 zesTH^{SVx{FX1O|8k#`{b{n86A%&m(l~Y;S{HEPwXD1K$HZ{{X?e(O}bAB8;NS~hw z$BS0_8aOZ1hwO)&oE&;J8{TXG(;O6E+M|IbMy9PRbXS$Eps2ei1?){zsjX%lj&17whE7KQ8a(kuHBbXk6}iD6`7g=Up!F7r6)k(tJlXd@-Oa*@g?@ z_gGwa+k=&FeNf((jaBYTd_Zo1(berv%mFaS98z? zO)`_UU!OH0N#|rv-H}A~&M8+_KZ}Nv3Xg*-8W(awfmkv<*OgfPUL?z0J2jeBJ(ElB z8b2g*&S%^$rxlR>Q%&?X&nno9N|cT$4U15iH`Q(k7AChcj{P>Uvz9aN4vffAbvsVQ zh0>*2lKi!Evzbt=e{o~=tk{GKXXfa_fmVQ?o?RiyuWaS6tW{aMWMArN4)%cO-y_=#_42&_;#{p+Rp=Tj>@aIiLsWbQ)11+U)H`i4}Hg3RI`>`0ENkK zd&JQ(ZK7f_L>-ARFJ9tVA?o?2Qt(_lRP$OWUMJSwp2rvz)KaCYcIBmkX+J}$Z0#@{ zqplL1wV(TmkoL2-eO(oP_)BH7oY(v2K+XX3bLNTgRz<5KCL;j>R-G! zXIINtv)4T!=z`6vxs}t3Yagd;<%NrCj{}o4T{TrSJ{1Nhiw-A?6}Le2DV}>d={@lo zmT@w3mrqkIZ?a#xImF+eKL|L;x{>I-bB~2(ZO8(uJ9|!cS)jqk8(;qFp{>R{kCgSt z(fu9uyc9G?)|>gNN3m2&)eL3wKh!7~i1IkBZ4A@Xh^xElA}{_l{zdK?0O6*%HLO-$ zu?|kR>2emtA9LA6K4b7)xYw~hB0(jp0HDi_aRx&JJ>}hF_HDKwx&KPD!UJq{IfW51 zAUTjbnzfZw3DYK+c~()lg)IgzmxZAesazyH$Rcn(=MfaiPu2}9WFW*&a=!x(6{mv?R?q=lLv!|)3wl)`v|KS_ukd>li#(SdU-Mrip)&J~_0XNZ9Rqr9vekl*u*I z0q6sqfDmg6Y6K+@MmVOy^MNF3Sx3wSx)eYk^J6-ju&_|7aH-J4-tgdGmSpm1t_;pT zNwA?esUd30nnH!Z){wVH(Li$-D?`7&!dBNg(t2(zwJ4jgPcS`c%#Z?ZVnAHo-%PSJ zJ#gl_SLQ@DECW~TtYsS;8>+hBGObWPp_?PCKR)DKL^3jB`NI0cpkeLuO2tKp@2)0^Lu z8fJf+vn5w@NS5Ut)0u6$oRu1zoiN@?MS=|qQ&r5u#nfUBxv98-%{UxXBo}~kH#_B4 zg<1=#QOn8`oe%bjiC84Og^}UGh%lY?KTHe1A$8$UD-Z@M5l0f%XK{oZ$v~FlgxF5H zqpHkmG*RhHFhIG;s|95suMuYTdT4V;A&GK6!N2z<4UqEr-ksph-&&%8fA<&TxGySG zy>L@a3>4kb#0#hBvhA?IiNg(b?{vp`O+^yQO@EtwgAI^#t75Mb6^RiuVMzVxAlZ&v zo-rp*!GmC^WGQ93kfM*|q}KLpH7CKUFjcdPyELzTtF23(JCtr)(rp!D?x7z?zh!@W z?*g=#%ue5uhog23Kc;J;PMlBLW7qs-W_TPpyk+n8he&-Rgn_A@#HH6`KU zOB+qF5`5&sc|Bsso7z0iQAE%EXPpXa7Q9=Xi|qN zfWbgafiPkamhI{t=Hm(t#?)%pUws84*9&K3jkzuz=QlZ)x+RJ(eEa7=Q@#s^QPkpkvv05Vskc z28ufzw;7sYZW*k9eYp)Y^VFELul-wXUxO=NPdECuwO`+aT)!3vz{Dekj!jDCoZ(~k zn-ow!g-cr$ZWQi{s1*%f6fR00EsM<#S&^jzD?>4GmMjW619LQmb4x$l>Sy}_fo;*kU$Aj;aeH1;}T75$^?v=Q`H*1^{c)E*X8?Jvr%)uQ` z!TB8on>p(=8SuRv9>xy*e({hb0Dgvi{3Xt5jdEWxsWHW_7i{YT&%H3e$WVU0$ftg7+Wqq?hlk&^Yv<1%d^u@Q8 z%zrORx;}ouCBu*}E(Lo7^G|0nr4W`sg}M=P|4aa!L_(SBg(j4cb^KmB#X17J*lRe4 zvNW(mOcXI@AP%GnVBAnikbX*#v6^P7LJApuLT#l8LO^5T6F!%R*1S*F-=fxRG&)&-UW$~ zkGh{LgllddRJpY$j#@s*cftBKr^cVQyl4lzd~csT8IWK0@@Oh}`DS@De|Z}KV8G{^ zDa``Xh#@yT0x$t*FGf}qPp+Y}0xH*Ki1x>g!p2yj#jy$tABn>VkB9K+HDa;eQ!mD_ z#j6$m8ho%}{CMtVbE`vCP5;9`fh1G|?b5TOE`p+}546=oAfEqEd)F1!)Yh$cLI?p8 z2u+G~0#X75M5P@KJwQ~NfCuTJD@8z1I2uBSfb=Fm5}H9N2NdZT1S!%&v(bwIR6Ho6 z6d^aBJI>>IzvGU7KCQji%baU`d#<_m`sSR1*H6g4(P8f0s)@=k*f)G>gv6xY|NKz( zsJ`Zp%@Z@9q>am{=MQEMrVnl8$_{omkHvldM>Q>1?oc5c2h4|K0tz4>j4sqY;~3@w zm`LN30pfy10Tj_kAe{;j>Fe4Jd}+R&5A(fBZZ1WRmH5qUv;4v0MW zUY(YVR;m;0C&`~cBoU(UJB~$O3QkXCK`!=+O>mmL5Nl(-z+aKj@h7M(@ZGtsIjLfo zBbl+2N7~IwZ*F$UJklMn^?@aS2x_JjtP%m~X!z%}p99tA_~Kl}!6bV!p$8SmNoF@d zcW-f%AqEf{gku;&i=^S$XwncjIG}vJjJwt!QtCM99^`iA>8a;U*PLE4qh2Zb2QeQl z*njT|Ptw{Sn-6_fT}04li%H=!RFRbcrB+%5do;e3ju*6$ykh<2?AhGQrfwE_jFXJZ zE(Z~qnY_SQ&Ya+crr$;@6i+6c%$6c7Y1&*Um!lYJL~xH26*0V!Y!0j%MgSrJAVo0< zP&u%O022Wio8#9c%nclzr(r7(r=h44SHxmMR5DzC^cEK+j_oWU>YOA(9UkB&-L~EN z^ft{OAUD@Nhs|cY=2pJGdbBdgRGkj+4Vrv3T=|~dS8#X{U9vD7^?AH*&vp9fL(o>3 z)L)Wow`pvOi*Mk=i5NZ86yhu2HnvS|Y4~E=7C+dO2!dqVgL;P5u9iA0OdvWvKy1Bm z+k&rs(tRG7);cfXV-bOhVtDfx*?4Oz=n0tcjocqSVZtAm+D8I zCJrg~0Ccbxpv{wkL8~HUhT#kw;qYfeG*^pi66uDnaR(pe> z7~3$6sSA-E)qegWO!}5>!>liA`>k+*N=KzP4Ca9|EUJbVsvT*z^ zd7EKz=K4|9wVKy=JM%eP5pthO9*X>d)8}j)Bcz{bG3QbOf@L*GxKd>gF}IjtocKY2O>}Jwx;{)@q!FU z1$Ytum_0=aE>?_+MUsiuFphi;i?>D*UH}B_4n)hs`clOlyDQWQjz7FpwO8aP#+My( z3vaEZwjMF+7|um9yDK5|$q&u;ul2vY>zGFCJ4d)~e$g16#y4oF*ITQ0^kMu2WpXQI z!`vtg8BRyv%bO~Kwfz=rrOyinzPICK;fwv)AuCsM4^)wkG9w#e!*FmA_Pa6cOMKDF zQ_xL#L%YMd<4d1DM@3zXI+r#i!_n6M%{uFhZRoBU*TSjBLfe6)J==POPa$F460HTV zJvqnzKEWvcnzpXb_4sFV2RvOnK1mazI^RTR zk)yDG623Vxsy5;_Aqg7Z z?Fi@R(?xH6MptS-9{Ej*rX5aSFz>gkv9(@66>o871Db``+~;_0+y|_2GSg8ZeE7*OF1aZnsBl+4M)Qk_JI%#f%lqV_~TNl1ESnHoty(kAsR8D0$FOuOZ# zAZ%&4kL9U8%zMnzh$V&1`P~%v4bfd_W`Scpn3R*Gjt3Fxk#KA}$5s}vd7!?`h~w70 zJ**-^7{N$&UPhy=kWTq!+wscs!euJOoF4B+W5U<)dI7KH({uETiNd!5Kh&J4_#N)~ zTMzfe#3K?q)oQ1ihN$n`Lw`MYndfaoD?N+&{^Q0$^I3Hr=0d@@{emMN*q+D0rVvb< zB8$dD;T)t)H+W|N(0hey+=0~a=!MTeg2G1vd+jD{`4dPk|JiESD_KoZq_9s>w7x*G zA*kSB(WcpKynX+0 zj|fA0Jqct8_MrV%GZcJfy1-~F7)pdI#xEAbR=`VKM1c|j1UK*-`=L)~-$WMqrx`{e5B19%v>MEytIkgueq$F06N579xzrEYKsvkR0m70eV4yJt(xkZ=)aQi;izV5ww2jA{|cgK zWavNRVCaC3Hjhr-cDx^OB-h7F^C|rK${6#}K*L zi1uX8H~lC)k>3N7Ob}-4G~N&T$`3*1M|oaYZ9@NGI=L4)fu&GNRreMk0xx zF*dy$A~p5fYp1aAo~=+X5;;@dPO~#y>zK&gM()hWK-vJ`fvLhaVjL?8bd^NDS7MIx@9D{+SJo!Zh$^Anjfx2b_x*X9pRE5f#M& zZB?O;Nj?4aEy)i)eSw38Ps$yu2ecWU$1g_P+Ae>dICCx_2jdR0o0UKrqi1EIy{S_M z-R#Q{nYNi7d}G;_<9<*2S8wE?D?H|fLSBcSg;t8@2il+F9$4)!(E!%Yq`-ecE{(+r zjW%Or(dCNNleT^_G*h@GfAP>mnpXK%5G_Scxa{;IKB=tC^~ASx__XC`^blz+{9}V8 zdmkK0!D2{@5H?sPv_f^K@>j8_M{YL|>>ySG6q^Ew$a6&+!_Eb$Si3)mrEtf_#%b74 zvMh>6Ym<{++OB&(f4$yRH0P@KX<(y$m&=Lx1;P$obcE|)jEVae50aXkR=Shz&ZA9+^#_iC?+Q37%K(H56g)vRb&@M9qV192D2@7XWS`z zdF5!TirnothFYosEPgu zB|`?dM3o@OEkhCWSf$uqt$1P*t&|haON&baa;&7mVubVceH2>yu!dPX+HPjbR7du$ z4M#}X3rpR}g#hy=WBP`X{XU_DV`KXFg(uNA%-W%kx)%E*mc8;?J-;Bwx?*iQH=ueG z0A~$~BI~6AR|^A^R#yJZG)}QunGT@HZG|7gB*!S0w>ztY;_d#NZQ5-7Bi8gdqw`H? zN@Eu3DEmg|{nvXBn9Oreo3H=?24QJ#7!f!W0H96Sm@F{O7f7;tCys%Eh^6|Di6IJ5 zI5)8cmU%s606>bUQRe_?fy$TJdx{o=My2OT{#+lGlhwH0$byi*^IT~GwI|sOPKqx_ z%NY$CXU$#G=@_1M=P1qR8IM;ibFR&;nQ-&f84T;^ME#bQrOu;bweRh{LT?!m5#vWy zuUeK*6}v<@Ea3Ccgbj}jC6HrCCQF|@oDKLlWC_=pKj~ffs=NzBxS+7(RgY!ApgjD` z`8UD_arxc^4tn#H34H&)_>!PQWcIr;2+AI$TW1WXsBFo#f2O zj@DnLTimQLB3w{#3=sfTUz8?bGeifDaf=#B07MuU_(e-Hznfbrm7B;_XCn^4!H8BZ z;1ubZ6qV|gm1uB*+gG*r_rc&NoH1BDfxh~P;Xg6B{npgEcNb;2iTrGBN^X^rO`9xFY~YOXsYYcUz<-Ass(OT z6$X!I82qyDAIlHH`(cy#mUsyA%y~cI)INoW#%<2#|JYsRou*{~nGd{II%$l&s9yDc z)!I5atS!5sP&jpMl^isd=cK1`>xk_F7Nu!sWi*Fz6&TF zC}pD(TzX7P7uvm@at}NNM~D7hkwk@Y@YW$3G2`1)Xi$w zcpY&y)7HJn{{1C?_IN7AJD2L8;N6O{pLPhyZN20o@&Q^BW{eTjmq(E5^yMYFT8tx2 zbP;f9iM+0ShtS{8X|#!$YTFJWM-_BbtOfMZ)+M+4#fjKwq(_L`2#GcdivaHkcChY2 zpK`ZXJx&^REy}D8e=}r}kz#_b;RF2TeR;!ABRz?ipL1$45h20=8M8}Mm6 z_zfD8B}PuIvW)&_mktjGe|k<3B=Zp~RJiRzuKyyM7!9p|nfl+?V7~tW<0N0n diff --git a/core/artwork/reader_album.go b/core/artwork/folders_album.go similarity index 63% rename from core/artwork/reader_album.go rename to core/artwork/folders_album.go index 8ad07773b..d5abee2ee 100644 --- a/core/artwork/reader_album.go +++ b/core/artwork/folders_album.go @@ -3,106 +3,18 @@ package artwork import ( "cmp" "context" - "crypto/md5" "errors" - "fmt" - "io" "path" "slices" "strings" "time" "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core/external" - "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils" "github.com/navidrome/navidrome/utils/natural" ) -type albumArtworkReader struct { - cacheKey - a *artwork - provider external.Provider - album model.Album - updatedAt *time.Time - imgFiles []string // library-relative, forward-slash, no leading slash - lib libraryView -} - -func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*albumArtworkReader, error) { - al, err := artwork.ds.Album(ctx).Get(artID.ID) - if err != nil { - return nil, err - } - _, imgFiles, imagesUpdateAt, err := loadAlbumFoldersPaths(ctx, artwork.ds, *al) - if err != nil { - return nil, err - } - lib, err := loadLibraryView(ctx, artwork.ds, al.LibraryID) - if err != nil { - return nil, err - } - a := &albumArtworkReader{ - a: artwork, - provider: provider, - album: *al, - updatedAt: imagesUpdateAt, - imgFiles: imgFiles, - lib: lib, - } - a.cacheKey.artID = artID - a.cacheKey.lastUpdate = utils.TimeNewest(al.UpdatedAt, al.ImportedAt) - if imagesUpdateAt != nil { - a.cacheKey.lastUpdate = utils.TimeNewest(a.cacheKey.lastUpdate, *imagesUpdateAt) - } - return a, nil -} - -func (a *albumArtworkReader) Key() string { - hashInput := conf.Server.CoverArtPriority - if conf.Server.EnableExternalServices { - hashInput = conf.Server.Agents + hashInput - } - hash := md5.Sum([]byte(hashInput)) - return fmt.Sprintf( - "%s.%x.%t", - a.cacheKey.Key(), - hash, - conf.Server.EnableExternalServices, - ) -} -func (a *albumArtworkReader) LastUpdated() time.Time { - return a.lastUpdate -} - -func (a *albumArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { - var ff = a.fromCoverArtPriority(ctx, a.a.ffmpeg, conf.Server.CoverArtPriority) - return selectImageReader(ctx, a.artID, ff...) -} - -func (a *albumArtworkReader) fromCoverArtPriority(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string) []sourceFunc { - var ff []sourceFunc - for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") { - pattern = strings.TrimSpace(pattern) - switch { - case pattern == "embedded": - embedRel := a.album.EmbedArtPath - ff = append(ff, - fromTag(ctx, a.lib.FS, embedRel), - fromFFmpegTag(ctx, ffmpeg, a.lib.Abs(embedRel)), - ) - case pattern == "external": - ff = append(ff, fromAlbumExternalSource(ctx, a.album, a.provider)) - case len(a.imgFiles) > 0: - ff = append(ff, fromExternalFile(ctx, a.lib.FS, a.imgFiles, pattern)) - } - } - return ff -} - func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...model.Album) ([]string, []string, *time.Time, error) { var folderIDs []string for _, album := range albums { diff --git a/core/artwork/reader_artist.go b/core/artwork/folders_artist.go similarity index 52% rename from core/artwork/reader_artist.go rename to core/artwork/folders_artist.go index 37b7b6dee..b75cddde6 100644 --- a/core/artwork/reader_artist.go +++ b/core/artwork/folders_artist.go @@ -2,7 +2,6 @@ package artwork import ( "context" - "crypto/md5" "fmt" "io" "io/fs" @@ -14,9 +13,7 @@ import ( "time" "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/str" @@ -28,127 +25,6 @@ const ( maxArtistFolderTraversalDepth = 3 ) -type artistReader struct { - cacheKey - a *artwork - provider external.Provider - artist model.Artist - artistFolder string - imgFiles []string - imgFolderImgPath string // cached path from ArtistImageFolder lookup - lib libraryView -} - -func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*artistReader, error) { - ar, err := artwork.ds.Artist(ctx).Get(artID.ID) - if err != nil { - return nil, err - } - // Only consider albums where the artist is the sole album artist. - als, err := artwork.ds.Album(ctx).GetAll(model.QueryOptions{ - Filters: squirrel.And{ - squirrel.Eq{"album_artist_id": artID.ID}, - squirrel.Eq{"json_array_length(participants, '$.albumartist')": 1}, - }, - }) - if err != nil { - return nil, err - } - albumPaths, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, artwork.ds, als...) - if err != nil { - return nil, err - } - artistFolder, artistFolderLastUpdate, err := loadArtistFolder(ctx, artwork.ds, als, albumPaths) - if err != nil { - return nil, err - } - var lib libraryView - if len(als) > 0 { - lib, err = loadLibraryView(ctx, artwork.ds, als[0].LibraryID) - if err != nil { - return nil, err - } - } - a := &artistReader{ - a: artwork, - provider: provider, - artist: *ar, - artistFolder: artistFolder, - imgFiles: imgFiles, - lib: lib, - } - // TODO Find a way to factor in the ExternalUpdateInfoAt in the cache key. Problem is that it can - // change _after_ retrieving from external sources, making the key invalid - //a.cacheKey.lastUpdate = ar.ExternalInfoUpdatedAt - - a.cacheKey.lastUpdate = *imagesUpdatedAt - if ar.UpdatedAt != nil && ar.UpdatedAt.After(a.cacheKey.lastUpdate) { - a.cacheKey.lastUpdate = *ar.UpdatedAt - } - if artistFolderLastUpdate.After(a.cacheKey.lastUpdate) { - a.cacheKey.lastUpdate = artistFolderLastUpdate - } - if conf.Server.ArtistImageFolder != "" && strings.Contains(strings.ToLower(conf.Server.ArtistArtPriority), "image-folder") { - a.imgFolderImgPath = findImageInArtistFolder(conf.Server.ArtistImageFolder, ar.MbzArtistID, ar.Name) - if a.imgFolderImgPath != "" { - if info, err := os.Stat(a.imgFolderImgPath); err == nil && info.ModTime().After(a.cacheKey.lastUpdate) { - a.cacheKey.lastUpdate = info.ModTime() - } - } - } - a.cacheKey.artID = artID - return a, nil -} - -func (a *artistReader) Key() string { - hash := md5.Sum([]byte(conf.Server.Agents)) - return fmt.Sprintf( - "%s.%t.%x", - a.cacheKey.Key(), - conf.Server.EnableExternalServices, - hash, - ) -} - -func (a *artistReader) LastUpdated() time.Time { - return a.lastUpdate -} - -func (a *artistReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { - ff := []sourceFunc{a.fromArtistUploadedImage()} - ff = append(ff, a.fromArtistArtPriority(ctx, conf.Server.ArtistArtPriority)...) - return selectImageReader(ctx, a.artID, ff...) -} - -func (a *artistReader) fromArtistUploadedImage() sourceFunc { - return fromLocalFile(a.artist.UploadedImagePath()) -} - -func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority string) []sourceFunc { - var ff []sourceFunc - for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") { - pattern = strings.TrimSpace(pattern) - switch { - case pattern == "external": - ff = append(ff, fromArtistExternalSource(ctx, a.artist, a.provider)) - case pattern == "image-folder": - ff = append(ff, a.fromArtistImageFolder(ctx)) - case strings.HasPrefix(pattern, "album/"): - if a.lib.FS != nil { - ff = append(ff, fromExternalFile(ctx, a.lib.FS, a.imgFiles, strings.TrimPrefix(pattern, "album/"))) - } - default: - ff = append(ff, fromArtistFolder(ctx, a.lib.FS, a.lib.absRoot, a.artistFolder, pattern)) - } - } - return ff -} - -// fromArtistFolder walks up from artistFolder toward libPath looking for a -// file matching pattern. Traversal is bounded by both maxArtistFolderTraversalDepth -// and the library root: once we reach libPath (or if artistFolder is outside -// libPath), the walk stops. All reads go through libFS, which keeps artwork -// resolution scoped to the configured library. func fromArtistFolder(ctx context.Context, libFS fs.FS, libPath, artistFolder, pattern string) sourceFunc { return func() (io.ReadCloser, string, error) { if libFS == nil { @@ -262,29 +138,6 @@ func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albu return folderPath, folders[0].ImagesUpdatedAt, nil } -func (a *artistReader) fromArtistImageFolder(ctx context.Context) sourceFunc { - return func() (io.ReadCloser, string, error) { - folder := conf.Server.ArtistImageFolder - if folder == "" { - return nil, "", nil - } - // Use cached path from newArtistArtworkReader if available, - // avoiding a second directory scan. - path := a.imgFolderImgPath - if path == "" { - path = findImageInArtistFolder(folder, a.artist.MbzArtistID, a.artist.Name) - } - if path == "" { - return nil, "", fmt.Errorf("no image found for artist %q in %s", a.artist.Name, folder) - } - f, err := os.Open(path) - if err != nil { - return nil, "", err - } - return f, path, nil - } -} - // findImageInArtistFolder scans a folder for an image file matching the artist's MBID or name // (case-insensitive). Returns the full path, or empty string if not found. func findImageInArtistFolder(folder, mbzArtistID, artistName string) string { diff --git a/core/artwork/image_cache.go b/core/artwork/image_cache.go index ac0f63794..7d73b1bae 100644 --- a/core/artwork/image_cache.go +++ b/core/artwork/image_cache.go @@ -2,29 +2,21 @@ package artwork import ( "context" - "fmt" "io" "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/cache" "github.com/navidrome/navidrome/utils/singleton" ) -type cacheKey struct { - artID model.ArtworkID - lastUpdate time.Time -} - -func (k *cacheKey) Key() string { - return fmt.Sprintf( - "%s-%s.%d", - k.artID.Kind, - k.artID.ID, - k.lastUpdate.UnixMilli(), - ) +// artworkReader is the cache.Item the image cache loader dispatches on: Reader +// produces the (possibly resized) bytes to store under Key. +type artworkReader interface { + cache.Item + LastUpdated() time.Time + Reader(ctx context.Context) (io.ReadCloser, string, error) } type imageCache struct { diff --git a/core/artwork/playlist_cover.go b/core/artwork/playlist_cover.go new file mode 100644 index 000000000..627a06b49 --- /dev/null +++ b/core/artwork/playlist_cover.go @@ -0,0 +1,100 @@ +package artwork + +import ( + "context" + "image" + "image/draw" + "io" + "os" + "path/filepath" + "strings" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + xdraw "golang.org/x/image/draw" +) + +const tileSize = 600 + +func fromLocalFile(path string) sourceFunc { + return func() (io.ReadCloser, string, error) { + if path == "" { + return nil, "", nil + } + f, err := os.Open(path) + if err != nil { + return nil, "", err + } + return f, path, nil + } +} + +// findPlaylistSidecarPath scans the directory of the playlist file for a sidecar +// image file with the same base name (case-insensitive). Returns empty string if +// no matching image is found or if plsPath is empty. +func findPlaylistSidecarPath(ctx context.Context, plsPath string) string { + if plsPath == "" { + return "" + } + dir := filepath.Dir(plsPath) + base := strings.TrimSuffix(filepath.Base(plsPath), filepath.Ext(plsPath)) + + entries, err := os.ReadDir(dir) + if err != nil { + log.Warn(ctx, "Could not read directory for playlist sidecar", "dir", dir, err) + return "" + } + for _, entry := range entries { + name := entry.Name() + nameBase := strings.TrimSuffix(name, filepath.Ext(name)) + if !entry.IsDir() && strings.EqualFold(nameBase, base) && model.IsImageFile(name) { + return filepath.Join(dir, name) + } + } + return "" +} + +func rect(pos int) image.Rectangle { + r := image.Rectangle{} + switch pos { + case 1: + r.Min.X = tileSize / 2 + case 2: + r.Min.Y = tileSize / 2 + case 3: + r.Min.X = tileSize / 2 + r.Min.Y = tileSize / 2 + } + r.Max.X = r.Min.X + tileSize/2 + r.Max.Y = r.Min.Y + tileSize/2 + return r +} + +// fillCenter crops the source image from the center and scales it to fill dstW x dstH exactly, +// equivalent to imaging.Fill with Center anchor. +func fillCenter(src image.Image, dstW, dstH int) image.Image { + srcBounds := src.Bounds() + srcW := srcBounds.Dx() + srcH := srcBounds.Dy() + + // Calculate crop rectangle (center crop to match destination aspect ratio) + srcAspect := float64(srcW) / float64(srcH) + dstAspect := float64(dstW) / float64(dstH) + + var cropRect image.Rectangle + if srcAspect > dstAspect { + // Source is wider — crop horizontally + cropW := int(float64(srcH) * dstAspect) + cropX := (srcW - cropW) / 2 + cropRect = image.Rect(srcBounds.Min.X+cropX, srcBounds.Min.Y, srcBounds.Min.X+cropX+cropW, srcBounds.Max.Y) + } else { + // Source is taller — crop vertically + cropH := int(float64(srcW) / dstAspect) + cropY := (srcH - cropH) / 2 + cropRect = image.Rect(srcBounds.Min.X, srcBounds.Min.Y+cropY, srcBounds.Max.X, srcBounds.Min.Y+cropY+cropH) + } + + dst := image.NewNRGBA(image.Rect(0, 0, dstW, dstH)) + xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, cropRect, draw.Src, nil) + return dst +} diff --git a/core/artwork/reader_album_test.go b/core/artwork/reader_album_test.go deleted file mode 100644 index fe4a1a545..000000000 --- a/core/artwork/reader_album_test.go +++ /dev/null @@ -1,454 +0,0 @@ -package artwork - -import ( - "context" - "errors" - "time" - - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Album Artwork Reader", func() { - Describe("loadAlbumFoldersPaths", func() { - var ( - ctx context.Context - ds *fakeDataStore - repo *fakeFolderRepo - album model.Album - now time.Time - expectedAt time.Time - ) - - BeforeEach(func() { - ctx = context.Background() - now = time.Now().Truncate(time.Second) - expectedAt = now.Add(5 * time.Minute) - - // Set up the test folders with image files - repo = &fakeFolderRepo{} - ds = &fakeDataStore{ - folderRepo: repo, - } - album = model.Album{ - ID: "album1", - Name: "Album", - FolderIDs: []string{"folder1", "folder2", "folder3"}, - } - }) - - It("returns sorted image files", func() { - repo.result = []model.Folder{ - { - Path: "Artist/Album/Disc1", - ImagesUpdatedAt: expectedAt, - ImageFiles: []string{"cover.jpg", "back.jpg", "cover.1.jpg"}, - }, - { - Path: "Artist/Album/Disc2", - ImagesUpdatedAt: now, - ImageFiles: []string{"cover.jpg"}, - }, - { - Path: "Artist/Album/Disc10", - ImagesUpdatedAt: now, - ImageFiles: []string{"cover.jpg"}, - }, - } - - _, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(*imagesUpdatedAt).To(Equal(expectedAt)) - - // Check that image files are sorted by base name (without extension) - Expect(imgFiles).To(HaveLen(5)) - - // Files should be sorted by base filename without extension, then by full path - // "back" < "cover", so back.jpg comes first - // Then all cover.jpg files, sorted by path - Expect(imgFiles[0]).To(Equal("Artist/Album/Disc1/back.jpg")) - Expect(imgFiles[1]).To(Equal("Artist/Album/Disc1/cover.jpg")) - Expect(imgFiles[2]).To(Equal("Artist/Album/Disc2/cover.jpg")) - Expect(imgFiles[3]).To(Equal("Artist/Album/Disc10/cover.jpg")) - Expect(imgFiles[4]).To(Equal("Artist/Album/Disc1/cover.1.jpg")) - }) - - It("prioritizes files without numeric suffixes", func() { - // Test case for issue #4683: cover.jpg should come before cover.1.jpg - repo.result = []model.Folder{ - { - Path: "Artist/Album", - ImagesUpdatedAt: now, - ImageFiles: []string{"cover.1.jpg", "cover.jpg", "cover.2.jpg"}, - }, - } - - _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(imgFiles).To(HaveLen(3)) - - // cover.jpg should come first because "cover" < "cover.1" < "cover.2" - Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg")) - Expect(imgFiles[1]).To(Equal("Artist/Album/cover.1.jpg")) - Expect(imgFiles[2]).To(Equal("Artist/Album/cover.2.jpg")) - }) - - It("handles case-insensitive sorting", func() { - // Test that Cover.jpg and cover.jpg are treated as equivalent - repo.result = []model.Folder{ - { - Path: "Artist/Album", - ImagesUpdatedAt: now, - ImageFiles: []string{"Folder.jpg", "cover.jpg", "BACK.jpg"}, - }, - } - - _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(imgFiles).To(HaveLen(3)) - - // Files should be sorted case-insensitively: BACK, cover, Folder - Expect(imgFiles[0]).To(Equal("Artist/Album/BACK.jpg")) - Expect(imgFiles[1]).To(Equal("Artist/Album/cover.jpg")) - Expect(imgFiles[2]).To(Equal("Artist/Album/Folder.jpg")) - }) - - It("includes images from parent folder for multi-disc albums", func() { - // Simulates: Artist/Album/cover.jpg with tracks in Artist/Album/CD1/ and Artist/Album/CD2/ - repo.result = []model.Folder{ - { - ID: "folder1", - Path: "Artist/Album", - Name: "CD1", - ParentID: "parentFolder", - ImagesUpdatedAt: now, - ImageFiles: []string{}, - }, - { - ID: "folder2", - Path: "Artist/Album", - Name: "CD2", - ParentID: "parentFolder", - ImagesUpdatedAt: now, - ImageFiles: []string{}, - }, - } - repo.parentResult = &model.Folder{ - ID: "parentFolder", - Path: "Artist", - Name: "Album", - ParentID: "artistFolder", - ImagesUpdatedAt: expectedAt, - ImageFiles: []string{"cover.jpg", "back.jpg"}, - } - - _, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(*imagesUpdatedAt).To(Equal(expectedAt)) - Expect(imgFiles).To(HaveLen(2)) - Expect(imgFiles[0]).To(Equal("Artist/Album/back.jpg")) - Expect(imgFiles[1]).To(Equal("Artist/Album/cover.jpg")) - }) - - It("does not query parent when parent ID is already in album folders", func() { - // When the parent folder is already one of the album's folders, skip it - repo.result = []model.Folder{ - { - ID: "folder1", - Path: "Artist", - Name: "Album", - ParentID: "folder2", - ImagesUpdatedAt: now, - ImageFiles: []string{"cover.jpg"}, - }, - { - ID: "folder2", - Path: "", - Name: "Artist", - ImagesUpdatedAt: now, - ImageFiles: []string{}, - }, - } - - _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(imgFiles).To(HaveLen(1)) - Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg")) - // Get should not have been called (parent already in folder set) - Expect(repo.getCallCount).To(Equal(0)) - }) - - It("does not query parent when folders have different parents", func() { - // When album folders span different parents, don't search any parent - repo.result = []model.Folder{ - { - ID: "folder1", - Path: "Artist1/Album", - Name: "part1", - ParentID: "parentA", - ImagesUpdatedAt: now, - ImageFiles: []string{"cover.jpg"}, - }, - { - ID: "folder2", - Path: "Artist2/Album", - Name: "part2", - ParentID: "parentB", - ImagesUpdatedAt: now, - ImageFiles: []string{}, - }, - } - - _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(imgFiles).To(HaveLen(1)) - Expect(imgFiles[0]).To(Equal("Artist1/Album/part1/cover.jpg")) - // Get should not have been called (different parents) - Expect(repo.getCallCount).To(Equal(0)) - }) - - It("does not include library root parent for multi-folder albums", func() { - // Two album parts directly under the library root — parent is the root itself - repo.result = []model.Folder{ - { - ID: "folder1", - Path: ".", - Name: "AlbumPart1", - ParentID: "rootFolder", - ImagesUpdatedAt: now, - ImageFiles: []string{"cover.jpg"}, - }, - { - ID: "folder2", - Path: ".", - Name: "AlbumPart2", - ParentID: "rootFolder", - ImagesUpdatedAt: now, - ImageFiles: []string{}, - }, - } - repo.parentResult = &model.Folder{ - ID: "rootFolder", - Path: "", - Name: ".", - ParentID: "", - ImageFiles: []string{"unrelated.jpg"}, - } - - _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(imgFiles).To(HaveLen(1)) - Expect(imgFiles[0]).To(Equal("AlbumPart1/cover.jpg")) - Expect(repo.getCallCount).To(Equal(1)) - }) - - It("includes top-level album folder for multi-disc albums", func() { - // Album folder directly under library root, with disc subfolders - repo.result = []model.Folder{ - { - ID: "folder1", - Path: "Album", - Name: "Disc1", - ParentID: "albumFolder", - ImagesUpdatedAt: now, - ImageFiles: []string{"folder.jpg"}, - }, - { - ID: "folder2", - Path: "Album", - Name: "Disc2", - ParentID: "albumFolder", - ImagesUpdatedAt: now, - ImageFiles: []string{"folder.jpg"}, - }, - } - repo.parentResult = &model.Folder{ - ID: "albumFolder", - Path: ".", - Name: "Album", - ParentID: "rootFolder", - ImagesUpdatedAt: expectedAt, - ImageFiles: []string{"cover.jpg"}, - } - - _, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(*imagesUpdatedAt).To(Equal(expectedAt)) - Expect(imgFiles).To(HaveLen(3)) - Expect(imgFiles[0]).To(Equal("Album/cover.jpg")) - Expect(imgFiles[1]).To(Equal("Album/Disc1/folder.jpg")) - Expect(imgFiles[2]).To(Equal("Album/Disc2/folder.jpg")) - Expect(repo.getCallCount).To(Equal(1)) - }) - - It("does not query parent for single-folder albums that already have images", func() { - repo.result = []model.Folder{ - { - ID: "folder1", - Path: "Artist", - Name: "Album", - ParentID: "artistFolder", - ImagesUpdatedAt: now, - ImageFiles: []string{"cover.jpg"}, - }, - } - - _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(imgFiles).To(HaveLen(1)) - Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg")) - Expect(repo.getCallCount).To(Equal(0)) - }) - - It("includes parent images for single-disc-subfolder albums", func() { - repo.result = []model.Folder{ - { - ID: "folder1", - Path: "Artist/Album", - Name: "disc1", - ParentID: "albumFolder", - ImagesUpdatedAt: now, - ImageFiles: []string{}, - }, - } - repo.parentResult = &model.Folder{ - ID: "albumFolder", - Path: "Artist", - Name: "Album", - ParentID: "artistFolder", - ImagesUpdatedAt: expectedAt, - ImageFiles: []string{"cover.jpg"}, - } - - _, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(*imagesUpdatedAt).To(Equal(expectedAt)) - Expect(imgFiles).To(HaveLen(1)) - Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg")) - Expect(repo.getCallCount).To(Equal(1)) - }) - - It("does not include parent images when other albums' audio lives under the parent", func() { - // Simulates: Artist/folder.jpg with Artist/Album (no images) and - // another album's tracks elsewhere under the artist folder - repo.result = []model.Folder{ - { - ID: "folder1", - Path: "Artist", - Name: "Album", - ParentID: "artistFolder", - ImagesUpdatedAt: now, - ImageFiles: []string{}, - }, - } - repo.parentResult = &model.Folder{ - ID: "artistFolder", - Path: ".", - Name: "Artist", - ParentID: "libraryRoot", - ImagesUpdatedAt: expectedAt, - ImageFiles: []string{"folder.jpg"}, - } - repo.hasOtherAudio = true - - _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(imgFiles).To(BeEmpty()) - }) - - It("propagates errors from the album-root check", func() { - repo.result = []model.Folder{ - { - ID: "folder1", - Path: "Artist/Album", - Name: "disc1", - ParentID: "albumFolder", - ImagesUpdatedAt: now, - ImageFiles: []string{}, - }, - } - repo.parentResult = &model.Folder{ - ID: "albumFolder", - Path: "Artist", - Name: "Album", - ParentID: "artistFolder", - ImagesUpdatedAt: expectedAt, - ImageFiles: []string{"cover.jpg"}, - } - repo.otherAudioErr = errors.New("db connection failed") - - _, _, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).To(MatchError("db connection failed")) - }) - - It("propagates non-ErrNotFound errors from parent folder lookup", func() { - repo.result = []model.Folder{ - { - ID: "folder1", - Path: "Artist/Album", - Name: "CD1", - ParentID: "parentFolder", - ImagesUpdatedAt: now, - ImageFiles: []string{"cover.jpg"}, - }, - { - ID: "folder2", - Path: "Artist/Album", - Name: "CD2", - ParentID: "parentFolder", - ImagesUpdatedAt: now, - ImageFiles: []string{}, - }, - } - repo.getErr = errors.New("db connection failed") - - _, _, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).To(MatchError("db connection failed")) - Expect(repo.getCallCount).To(Equal(1)) - }) - - It("continues gracefully when parent folder is not found", func() { - // Parent folder may have been deleted; should log a warning and continue - repo.result = []model.Folder{ - { - ID: "folder1", - Path: "Artist/Album", - Name: "CD1", - ParentID: "missingParent", - ImagesUpdatedAt: now, - ImageFiles: []string{"cover.jpg"}, - }, - { - ID: "folder2", - Path: "Artist/Album", - Name: "CD2", - ParentID: "missingParent", - ImagesUpdatedAt: now, - ImageFiles: []string{}, - }, - } - // parentResult is nil, so Get will return ErrNotFound - - _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(imgFiles).To(HaveLen(1)) - Expect(imgFiles[0]).To(Equal("Artist/Album/CD1/cover.jpg")) - Expect(repo.getCallCount).To(Equal(1)) - }) - }) -}) diff --git a/core/artwork/reader_artist_test.go b/core/artwork/reader_artist_test.go deleted file mode 100644 index 6d6d58fc5..000000000 --- a/core/artwork/reader_artist_test.go +++ /dev/null @@ -1,748 +0,0 @@ -package artwork - -import ( - "context" - "errors" - "io" - "io/fs" - "os" - "path/filepath" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("artistArtworkReader", func() { - var _ = Describe("loadArtistFolder", func() { - var ( - ctx context.Context - fds *fakeDataStore - repo *fakeFolderRepo - albums model.Albums - paths []string - now time.Time - expectedUpdTime time.Time - ) - - BeforeEach(func() { - ctx = context.Background() - DeferCleanup(stubCoreAbsolutePath()) - - now = time.Now().Truncate(time.Second) - expectedUpdTime = now.Add(5 * time.Minute) - repo = &fakeFolderRepo{ - result: []model.Folder{ - { - ImagesUpdatedAt: expectedUpdTime, - }, - }, - err: nil, - } - fds = &fakeDataStore{ - folderRepo: repo, - } - albums = model.Albums{ - {LibraryID: 1, ID: "album1", Name: "Album 1"}, - } - }) - - When("no albums provided", func() { - It("returns empty and zero time", func() { - folder, upd, err := loadArtistFolder(ctx, fds, model.Albums{}, []string{"/dummy/path"}) - Expect(err).ToNot(HaveOccurred()) - Expect(folder).To(BeEmpty()) - Expect(upd).To(BeZero()) - }) - }) - - When("artist has only one album", func() { - It("returns the parent folder", func() { - paths = []string{ - filepath.FromSlash("/music/artist/album1"), - } - folder, upd, err := loadArtistFolder(ctx, fds, albums, paths) - Expect(err).ToNot(HaveOccurred()) - Expect(folder).To(Equal(filepath.FromSlash("/music/artist"))) - Expect(upd).To(Equal(expectedUpdTime)) - }) - }) - - When("the artist have multiple albums", func() { - It("returns the common prefix for the albums paths", func() { - paths = []string{ - filepath.FromSlash("/music/library/artist/one"), - filepath.FromSlash("/music/library/artist/two"), - } - folder, upd, err := loadArtistFolder(ctx, fds, albums, paths) - Expect(err).ToNot(HaveOccurred()) - Expect(folder).To(Equal(filepath.FromSlash("/music/library/artist"))) - Expect(upd).To(Equal(expectedUpdTime)) - }) - }) - - When("the album paths contain same prefix", func() { - It("returns the common prefix", func() { - paths = []string{ - filepath.FromSlash("/music/artist/album1"), - filepath.FromSlash("/music/artist/album2"), - } - folder, upd, err := loadArtistFolder(ctx, fds, albums, paths) - Expect(err).ToNot(HaveOccurred()) - Expect(folder).To(Equal(filepath.FromSlash("/music/artist"))) - Expect(upd).To(Equal(expectedUpdTime)) - }) - }) - - When("ds.Folder().GetAll returns an error", func() { - It("returns an error", func() { - paths = []string{ - filepath.FromSlash("/music/artist/album1"), - filepath.FromSlash("/music/artist/album2"), - } - repo.err = errors.New("fake error") - folder, upd, err := loadArtistFolder(ctx, fds, albums, paths) - Expect(err).To(MatchError(ContainSubstring("fake error"))) - // Folder and time are empty on error. - Expect(folder).To(BeEmpty()) - Expect(upd).To(BeZero()) - }) - }) - }) - - var _ = Describe("fromArtistFolder", func() { - var ( - ctx context.Context - tempDir string - libFS fs.FS - testFunc sourceFunc - ) - - BeforeEach(func() { - ctx = context.Background() - tempDir = GinkgoT().TempDir() - libFS = os.DirFS(tempDir) - }) - - When("artist folder contains matching image", func() { - BeforeEach(func() { - // Create test structure: /temp/artist/artist.jpg - artistDir := filepath.Join(tempDir, "artist") - Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) - - artistImagePath := filepath.Join(artistDir, "artist.jpg") - Expect(os.WriteFile(artistImagePath, []byte("fake image data"), 0600)).To(Succeed()) - - testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") - }) - - It("finds and returns the image", func() { - reader, path, err := testFunc() - Expect(err).ToNot(HaveOccurred()) - Expect(reader).ToNot(BeNil()) - Expect(path).To(ContainSubstring("artist.jpg")) - - // Verify we can read the content - data, err := io.ReadAll(reader) - Expect(err).ToNot(HaveOccurred()) - Expect(string(data)).To(Equal("fake image data")) - reader.Close() - }) - }) - - When("artist folder name contains glob metacharacters", func() { - BeforeEach(func() { - artistDir := filepath.Join(tempDir, "Artist [Live]") - Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) - - artistImagePath := filepath.Join(artistDir, "artist.jpg") - Expect(os.WriteFile(artistImagePath, []byte("bracketed artist image"), 0600)).To(Succeed()) - - testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") - }) - - It("treats the folder path literally when globbing through the library fs", func() { - reader, path, err := testFunc() - Expect(err).ToNot(HaveOccurred()) - Expect(reader).ToNot(BeNil()) - Expect(path).To(ContainSubstring("Artist [Live]" + string(filepath.Separator) + "artist.jpg")) - - data, err := io.ReadAll(reader) - Expect(err).ToNot(HaveOccurred()) - Expect(string(data)).To(Equal("bracketed artist image")) - reader.Close() - }) - }) - - When("artist folder is empty but parent contains image", func() { - BeforeEach(func() { - // Create test structure: /temp/parent/artist.jpg and /temp/parent/artist/album/ - parentDir := filepath.Join(tempDir, "parent") - artistDir := filepath.Join(parentDir, "artist") - albumDir := filepath.Join(artistDir, "album") - Expect(os.MkdirAll(albumDir, 0755)).To(Succeed()) - - // Put artist image in parent directory - artistImagePath := filepath.Join(parentDir, "artist.jpg") - Expect(os.WriteFile(artistImagePath, []byte("parent image"), 0600)).To(Succeed()) - - testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") - }) - - It("finds image in parent directory", func() { - reader, path, err := testFunc() - Expect(err).ToNot(HaveOccurred()) - Expect(reader).ToNot(BeNil()) - Expect(path).To(ContainSubstring("parent" + string(filepath.Separator) + "artist.jpg")) - - data, err := io.ReadAll(reader) - Expect(err).ToNot(HaveOccurred()) - Expect(string(data)).To(Equal("parent image")) - reader.Close() - }) - }) - - When("image is two levels up", func() { - BeforeEach(func() { - // Create test structure: /temp/grandparent/artist.jpg and /temp/grandparent/parent/artist/ - grandparentDir := filepath.Join(tempDir, "grandparent") - parentDir := filepath.Join(grandparentDir, "parent") - artistDir := filepath.Join(parentDir, "artist") - Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) - - // Put artist image in grandparent directory - artistImagePath := filepath.Join(grandparentDir, "artist.jpg") - Expect(os.WriteFile(artistImagePath, []byte("grandparent image"), 0600)).To(Succeed()) - - testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") - }) - - It("finds image in grandparent directory", func() { - reader, path, err := testFunc() - Expect(err).ToNot(HaveOccurred()) - Expect(reader).ToNot(BeNil()) - Expect(path).To(ContainSubstring("grandparent" + string(filepath.Separator) + "artist.jpg")) - - data, err := io.ReadAll(reader) - Expect(err).ToNot(HaveOccurred()) - Expect(string(data)).To(Equal("grandparent image")) - reader.Close() - }) - }) - - When("images exist at multiple levels", func() { - BeforeEach(func() { - // Create test structure with images at multiple levels - grandparentDir := filepath.Join(tempDir, "grandparent") - parentDir := filepath.Join(grandparentDir, "parent") - artistDir := filepath.Join(parentDir, "artist") - Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) - - // Put artist images at all levels - Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist level"), 0600)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(parentDir, "artist.jpg"), []byte("parent level"), 0600)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(grandparentDir, "artist.jpg"), []byte("grandparent level"), 0600)).To(Succeed()) - - testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") - }) - - It("prioritizes the closest (artist folder) image", func() { - reader, path, err := testFunc() - Expect(err).ToNot(HaveOccurred()) - Expect(reader).ToNot(BeNil()) - Expect(path).To(ContainSubstring("artist" + string(filepath.Separator) + "artist.jpg")) - - data, err := io.ReadAll(reader) - Expect(err).ToNot(HaveOccurred()) - Expect(string(data)).To(Equal("artist level")) - reader.Close() - }) - }) - - When("pattern matches multiple files", func() { - BeforeEach(func() { - artistDir := filepath.Join(tempDir, "artist") - Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) - - // Create multiple matching files - Expect(os.WriteFile(filepath.Join(artistDir, "artist.abc"), []byte("text file"), 0600)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(artistDir, "artist.png"), []byte("png image"), 0600)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("jpg image"), 0600)).To(Succeed()) - - testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") - }) - - It("returns the first valid image file in sorted order", func() { - reader, path, err := testFunc() - Expect(err).ToNot(HaveOccurred()) - Expect(reader).ToNot(BeNil()) - - // Should return an image file, - // Files are sorted: jpg comes before png alphabetically. - // .abc comes first, but it's not an image. - Expect(path).To(ContainSubstring("artist.jpg")) - reader.Close() - }) - }) - - When("prioritizing files without numeric suffixes", func() { - BeforeEach(func() { - // Test case for issue #4683: artist.jpg should come before artist.1.jpg - artistDir := filepath.Join(tempDir, "artist") - Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) - - // Create multiple matches with and without numeric suffixes - Expect(os.WriteFile(filepath.Join(artistDir, "artist.1.jpg"), []byte("artist 1"), 0600)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist main"), 0600)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(artistDir, "artist.2.jpg"), []byte("artist 2"), 0600)).To(Succeed()) - - testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") - }) - - It("returns artist.jpg before artist.1.jpg and artist.2.jpg", func() { - reader, path, err := testFunc() - Expect(err).ToNot(HaveOccurred()) - Expect(reader).ToNot(BeNil()) - Expect(path).To(ContainSubstring("artist.jpg")) - - // Verify it's the main file, not a numbered variant - data, err := io.ReadAll(reader) - Expect(err).ToNot(HaveOccurred()) - Expect(string(data)).To(Equal("artist main")) - reader.Close() - }) - }) - - When("handling case-insensitive sorting", func() { - BeforeEach(func() { - // Test case to ensure case-insensitive natural sorting - artistDir := filepath.Join(tempDir, "artist") - Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) - - // Create files with mixed case names - Expect(os.WriteFile(filepath.Join(artistDir, "Folder.jpg"), []byte("folder"), 0600)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist"), 0600)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(artistDir, "BACK.jpg"), []byte("back"), 0600)).To(Succeed()) - - testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "*.*") - }) - - It("sorts case-insensitively", func() { - reader, path, err := testFunc() - Expect(err).ToNot(HaveOccurred()) - Expect(reader).ToNot(BeNil()) - - // Should return artist.jpg first (case-insensitive: "artist" < "back" < "folder") - Expect(path).To(ContainSubstring("artist.jpg")) - - data, err := io.ReadAll(reader) - Expect(err).ToNot(HaveOccurred()) - Expect(string(data)).To(Equal("artist")) - reader.Close() - }) - }) - - When("no matching files exist anywhere", func() { - BeforeEach(func() { - artistDir := filepath.Join(tempDir, "artist") - Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) - - // Create non-matching files - Expect(os.WriteFile(filepath.Join(artistDir, "cover.jpg"), []byte("cover image"), 0600)).To(Succeed()) - - testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") - }) - - It("returns an error", func() { - reader, path, err := testFunc() - Expect(err).To(HaveOccurred()) - Expect(reader).To(BeNil()) - Expect(path).To(BeEmpty()) - Expect(err.Error()).To(ContainSubstring("no matches for 'artist.*'")) - Expect(err.Error()).To(ContainSubstring("parent directories")) - }) - }) - - When("directory traversal reaches filesystem root", func() { - BeforeEach(func() { - // Start from a shallow directory to test root boundary - artistDir := filepath.Join(tempDir, "artist") - Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) - - testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") - }) - - It("handles root boundary gracefully", func() { - reader, path, err := testFunc() - Expect(err).To(HaveOccurred()) - Expect(reader).To(BeNil()) - Expect(path).To(BeEmpty()) - // Should not panic or cause infinite loop - }) - }) - - When("file exists but cannot be opened", func() { - BeforeEach(func() { - artistDir := filepath.Join(tempDir, "artist") - Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) - - // Create a file that cannot be opened (permission denied) - restrictedFile := filepath.Join(artistDir, "artist.jpg") - Expect(os.WriteFile(restrictedFile, []byte("restricted"), 0600)).To(Succeed()) - - testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") - }) - - It("logs warning and continues searching", func() { - // This test depends on the ability to restrict file permissions - // For now, we'll just ensure it doesn't panic and returns appropriate error - reader, _, err := testFunc() - // The file should be readable in test environment, so this will succeed - // In a real scenario with permission issues, it would continue searching - if err == nil { - Expect(reader).ToNot(BeNil()) - reader.Close() - } - }) - }) - - When("single album artist scenario (original issue)", func() { - BeforeEach(func() { - // Simulate the exact folder structure from the issue: - // /music/artist/album1/ (single album) - // /music/artist/artist.jpg (artist image that should be found) - artistDir := filepath.Join(tempDir, "music", "artist") - albumDir := filepath.Join(artistDir, "album1") - Expect(os.MkdirAll(albumDir, 0755)).To(Succeed()) - - // Create artist.jpg in the artist folder (this was not being found before) - artistImagePath := filepath.Join(artistDir, "artist.jpg") - Expect(os.WriteFile(artistImagePath, []byte("single album artist image"), 0600)).To(Succeed()) - - // The fromArtistFolder is called with the artist folder path - testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") - }) - - It("finds artist.jpg in artist folder for single album artist", func() { - reader, path, err := testFunc() - Expect(err).ToNot(HaveOccurred()) - Expect(reader).ToNot(BeNil()) - Expect(path).To(ContainSubstring("artist.jpg")) - Expect(path).To(ContainSubstring("artist")) - - // Verify the content - data, err := io.ReadAll(reader) - Expect(err).ToNot(HaveOccurred()) - Expect(string(data)).To(Equal("single album artist image")) - reader.Close() - }) - }) - }) - - Describe("fromArtistUploadedImage", func() { - var ( - tempDir string - reader *artistReader - ) - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - tempDir = GinkgoT().TempDir() - conf.Server.DataFolder = conf.NewDir(tempDir) - - // Create the artwork/artist directory - Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "artist"), 0755)).To(Succeed()) - - reader = &artistReader{} - }) - - When("artist has an uploaded image", func() { - It("returns the uploaded image", func() { - imgPath := filepath.Join(tempDir, "artwork", "artist", "ar-1_test.jpg") - Expect(os.WriteFile(imgPath, []byte("uploaded artist image"), 0600)).To(Succeed()) - - reader.artist = model.Artist{ID: "ar-1", UploadedImage: "ar-1_test.jpg"} - sf := reader.fromArtistUploadedImage() - r, path, err := sf() - Expect(err).ToNot(HaveOccurred()) - Expect(r).ToNot(BeNil()) - Expect(path).To(Equal(imgPath)) - - data, err := io.ReadAll(r) - Expect(err).ToNot(HaveOccurred()) - Expect(string(data)).To(Equal("uploaded artist image")) - r.Close() - }) - }) - - When("artist has no uploaded image", func() { - It("returns nil reader (falls through)", func() { - reader.artist = model.Artist{ID: "ar-1"} - sf := reader.fromArtistUploadedImage() - r, path, err := sf() - Expect(err).ToNot(HaveOccurred()) - Expect(r).To(BeNil()) - Expect(path).To(BeEmpty()) - }) - }) - }) - - Describe("fromArtistImageFolder", func() { - var ( - ctx context.Context - tempDir string - ar *artistReader - ) - - BeforeEach(func() { - ctx = context.Background() - DeferCleanup(configtest.SetupConfig()) - tempDir = GinkgoT().TempDir() - ar = &artistReader{} - }) - - When("ArtistImageFolder is not configured", func() { - It("returns nil (skips)", func() { - conf.Server.ArtistImageFolder = "" - ar.artist = model.Artist{Name: "Test Artist"} - sf := ar.fromArtistImageFolder(ctx) - r, path, err := sf() - Expect(err).ToNot(HaveOccurred()) - Expect(r).To(BeNil()) - Expect(path).To(BeEmpty()) - }) - }) - - When("image exists matching MBID", func() { - It("finds the image by MBID", func() { - conf.Server.ArtistImageFolder = tempDir - mbid := "f27ec8db-af05-4f36-916e-3d57f91ecf5e" - imgPath := filepath.Join(tempDir, mbid+".jpg") - Expect(os.WriteFile(imgPath, []byte("mbid image"), 0600)).To(Succeed()) - - ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: mbid} - sf := ar.fromArtistImageFolder(ctx) - r, path, err := sf() - Expect(err).ToNot(HaveOccurred()) - Expect(r).ToNot(BeNil()) - Expect(path).To(Equal(imgPath)) - - data, err := io.ReadAll(r) - Expect(err).ToNot(HaveOccurred()) - Expect(string(data)).To(Equal("mbid image")) - r.Close() - }) - }) - - When("MBID match is case-insensitive", func() { - It("finds the image regardless of case", func() { - conf.Server.ArtistImageFolder = tempDir - mbid := "F27EC8DB-AF05-4F36-916E-3D57F91ECF5E" - imgPath := filepath.Join(tempDir, "f27ec8db-af05-4f36-916e-3d57f91ecf5e.png") - Expect(os.WriteFile(imgPath, []byte("mbid case image"), 0600)).To(Succeed()) - - ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: mbid} - sf := ar.fromArtistImageFolder(ctx) - r, path, err := sf() - Expect(err).ToNot(HaveOccurred()) - Expect(r).ToNot(BeNil()) - Expect(path).To(Equal(imgPath)) - r.Close() - }) - }) - - When("no MBID file exists but artist name file does", func() { - It("falls back to artist name match", func() { - conf.Server.ArtistImageFolder = tempDir - imgPath := filepath.Join(tempDir, "Test Artist.jpg") - Expect(os.WriteFile(imgPath, []byte("name image"), 0600)).To(Succeed()) - - ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: "nonexistent-mbid"} - sf := ar.fromArtistImageFolder(ctx) - r, path, err := sf() - Expect(err).ToNot(HaveOccurred()) - Expect(r).ToNot(BeNil()) - Expect(path).To(Equal(imgPath)) - - data, err := io.ReadAll(r) - Expect(err).ToNot(HaveOccurred()) - Expect(string(data)).To(Equal("name image")) - r.Close() - }) - }) - - When("artist name match is case-insensitive", func() { - It("matches regardless of case", func() { - conf.Server.ArtistImageFolder = tempDir - imgPath := filepath.Join(tempDir, "test artist.jpg") - Expect(os.WriteFile(imgPath, []byte("case insensitive"), 0600)).To(Succeed()) - - ar.artist = model.Artist{Name: "Test Artist"} - sf := ar.fromArtistImageFolder(ctx) - r, path, err := sf() - Expect(err).ToNot(HaveOccurred()) - Expect(r).ToNot(BeNil()) - Expect(path).To(Equal(imgPath)) - r.Close() - }) - }) - - When("both MBID and name files exist", func() { - It("prefers MBID over name match", func() { - conf.Server.ArtistImageFolder = tempDir - mbid := "f27ec8db-af05-4f36-916e-3d57f91ecf5e" - mbidPath := filepath.Join(tempDir, mbid+".jpg") - namePath := filepath.Join(tempDir, "Test Artist.jpg") - Expect(os.WriteFile(mbidPath, []byte("mbid image"), 0600)).To(Succeed()) - Expect(os.WriteFile(namePath, []byte("name image"), 0600)).To(Succeed()) - - ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: mbid} - sf := ar.fromArtistImageFolder(ctx) - r, path, err := sf() - Expect(err).ToNot(HaveOccurred()) - Expect(r).ToNot(BeNil()) - Expect(path).To(Equal(mbidPath)) - - data, err := io.ReadAll(r) - Expect(err).ToNot(HaveOccurred()) - Expect(string(data)).To(Equal("mbid image")) - r.Close() - }) - }) - - When("no matching image found", func() { - It("returns an error", func() { - conf.Server.ArtistImageFolder = tempDir - // Create an unrelated file - Expect(os.WriteFile(filepath.Join(tempDir, "other.jpg"), []byte("other"), 0600)).To(Succeed()) - - ar.artist = model.Artist{Name: "Test Artist"} - sf := ar.fromArtistImageFolder(ctx) - r, _, err := sf() - Expect(err).To(HaveOccurred()) - Expect(r).To(BeNil()) - Expect(err.Error()).To(ContainSubstring("no image found")) - }) - }) - - When("cached imgFolderImgPath is set", func() { - It("uses cached path instead of scanning", func() { - conf.Server.ArtistImageFolder = tempDir - imgPath := filepath.Join(tempDir, "cached.jpg") - Expect(os.WriteFile(imgPath, []byte("cached image"), 0600)).To(Succeed()) - - ar.artist = model.Artist{Name: "Test Artist"} - ar.imgFolderImgPath = imgPath - sf := ar.fromArtistImageFolder(ctx) - r, path, err := sf() - Expect(err).ToNot(HaveOccurred()) - Expect(r).ToNot(BeNil()) - Expect(path).To(Equal(imgPath)) - - data, err := io.ReadAll(r) - Expect(err).ToNot(HaveOccurred()) - Expect(string(data)).To(Equal("cached image")) - r.Close() - }) - }) - }) - - Describe("findImageInArtistFolder", func() { - var tempDir string - - BeforeEach(func() { - tempDir = GinkgoT().TempDir() - }) - - When("matching file exists by MBID", func() { - It("returns the file path", func() { - mbid := "f27ec8db-af05-4f36-916e-3d57f91ecf5e" - imgPath := filepath.Join(tempDir, mbid+".jpg") - Expect(os.WriteFile(imgPath, []byte("image"), 0600)).To(Succeed()) - - path := findImageInArtistFolder(tempDir, mbid, "Test") - Expect(path).To(Equal(imgPath)) - }) - }) - - When("matching file exists by name", func() { - It("returns the file path", func() { - imgPath := filepath.Join(tempDir, "Test Artist.png") - Expect(os.WriteFile(imgPath, []byte("image"), 0600)).To(Succeed()) - - path := findImageInArtistFolder(tempDir, "", "Test Artist") - Expect(path).To(Equal(imgPath)) - }) - }) - - When("no matching file exists", func() { - It("returns empty string", func() { - path := findImageInArtistFolder(tempDir, "", "Unknown Artist") - Expect(path).To(BeEmpty()) - }) - }) - - When("folder does not exist", func() { - It("returns empty string", func() { - path := findImageInArtistFolder("/nonexistent/path", "", "Test") - Expect(path).To(BeEmpty()) - }) - }) - }) -}) - -type fakeFolderRepo struct { - model.FolderRepository - result []model.Folder - parentResult *model.Folder - getErr error - getCallCount int - err error - // hasOtherAudio is returned by HasAudioOutsideFolders (the album-root - // check). False means the parent qualifies as an album root. - hasOtherAudio bool - otherAudioErr error -} - -func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) { - return f.result, f.err -} - -func (f *fakeFolderRepo) HasAudioOutsideFolders(model.Folder, []string) (bool, error) { - return f.hasOtherAudio, f.otherAudioErr -} - -func (f *fakeFolderRepo) Get(id string) (*model.Folder, error) { - f.getCallCount++ - if f.getErr != nil { - return nil, f.getErr - } - if f.parentResult != nil { - return f.parentResult, nil - } - return nil, model.ErrNotFound -} - -type fakeDataStore struct { - model.DataStore - folderRepo *fakeFolderRepo -} - -func (fds *fakeDataStore) Folder(_ context.Context) model.FolderRepository { - return fds.folderRepo -} - -func stubCoreAbsolutePath() func() { - // Override core.AbsolutePath to return a fixed string during tests. - original := core.AbsolutePath - core.AbsolutePath = func(_ context.Context, ds model.DataStore, libID int, p string) string { - return filepath.FromSlash("/music") - } - return func() { - core.AbsolutePath = original - } -} diff --git a/core/artwork/reader_mediafile.go b/core/artwork/reader_mediafile.go deleted file mode 100644 index eac3c5e70..000000000 --- a/core/artwork/reader_mediafile.go +++ /dev/null @@ -1,82 +0,0 @@ -package artwork - -import ( - "context" - "fmt" - "io" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/model" -) - -type mediafileArtworkReader struct { - cacheKey - a *artwork - mediafile model.MediaFile - album model.Album - lib libraryView -} - -func newMediafileArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID) (*mediafileArtworkReader, error) { - mf, err := artwork.ds.MediaFile(ctx).Get(artID.ID) - if err != nil { - return nil, err - } - al, err := artwork.ds.Album(ctx).Get(mf.AlbumID) - if err != nil { - return nil, err - } - _, _, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, artwork.ds, *al) - if err != nil { - return nil, err - } - lib, err := loadLibraryView(ctx, artwork.ds, mf.LibraryID) - if err != nil { - return nil, err - } - a := &mediafileArtworkReader{ - a: artwork, - mediafile: *mf, - album: *al, - lib: lib, - } - a.cacheKey.artID = artID - a.cacheKey.lastUpdate = mf.UpdatedAt - if al.UpdatedAt.After(a.cacheKey.lastUpdate) { - a.cacheKey.lastUpdate = al.UpdatedAt - } - if imagesUpdatedAt != nil && imagesUpdatedAt.After(a.cacheKey.lastUpdate) { - a.cacheKey.lastUpdate = *imagesUpdatedAt - } - return a, nil -} - -func (a *mediafileArtworkReader) Key() string { - return fmt.Sprintf( - "%s.%t", - a.cacheKey.Key(), - conf.Server.EnableMediaFileCoverArt, - ) -} -func (a *mediafileArtworkReader) LastUpdated() time.Time { - return a.lastUpdate -} - -func (a *mediafileArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { - var ff []sourceFunc - if a.mediafile.CoverArtID().Kind == model.KindMediaFileArtwork { - ff = []sourceFunc{ - fromTag(ctx, a.lib.FS, a.mediafile.Path), - fromFFmpegTag(ctx, a.a.ffmpeg, a.lib.Abs(a.mediafile.Path)), - } - } - // For multi-disc albums, fall back to disc artwork first; for single-disc albums, - // skip disc resolution (it would just fall through to album art anyway). - if len(a.album.Discs) > 1 { - ff = append(ff, fromAlbum(ctx, a.a, a.mediafile.DiscCoverArtID())) - } else { - ff = append(ff, fromAlbum(ctx, a.a, a.mediafile.AlbumCoverArtID())) - } - return selectImageReader(ctx, a.artID, ff...) -} diff --git a/core/artwork/reader_playlist.go b/core/artwork/reader_playlist.go deleted file mode 100644 index 09707843d..000000000 --- a/core/artwork/reader_playlist.go +++ /dev/null @@ -1,269 +0,0 @@ -package artwork - -import ( - "bytes" - "context" - "errors" - "image" - "image/draw" - "image/png" - "io" - "net/url" - "os" - "path/filepath" - "strings" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/slice" - xdraw "golang.org/x/image/draw" -) - -type playlistArtworkReader struct { - cacheKey - a *artwork - pl model.Playlist -} - -const tileSize = 600 - -func newPlaylistArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID) (*playlistArtworkReader, error) { - pl, err := artwork.ds.Playlist(ctx).Get(artID.ID) - if err != nil { - return nil, err - } - a := &playlistArtworkReader{ - a: artwork, - pl: *pl, - } - a.cacheKey.artID = artID - a.cacheKey.lastUpdate = pl.UpdatedAt - - // Check sidecar and ExternalImageURL local file ModTimes for cache invalidation. - // If either is newer than the playlist's UpdatedAt, use that instead so the - // cache is busted when a user replaces a sidecar image or local file reference. - for _, path := range []string{ - findPlaylistSidecarPath(ctx, pl.Path), - pl.ExternalImageURL, - } { - if path == "" || strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { - continue - } - if info, err := os.Stat(path); err == nil { - if info.ModTime().After(a.cacheKey.lastUpdate) { - a.cacheKey.lastUpdate = info.ModTime() - } - } - } - - return a, nil -} - -func (a *playlistArtworkReader) LastUpdated() time.Time { - return a.lastUpdate -} - -func (a *playlistArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { - return selectImageReader(ctx, a.artID, - a.fromPlaylistUploadedImage(), - a.fromPlaylistSidecar(ctx), - a.fromPlaylistExternalImage(ctx), - a.fromGeneratedTiledCover(ctx), - fromAlbumPlaceholder(), - ) -} - -func (a *playlistArtworkReader) fromPlaylistUploadedImage() sourceFunc { - return fromLocalFile(a.pl.UploadedImagePath()) -} - -func (a *playlistArtworkReader) fromPlaylistSidecar(ctx context.Context) sourceFunc { - return fromLocalFile(findPlaylistSidecarPath(ctx, a.pl.Path)) -} - -func (a *playlistArtworkReader) fromPlaylistExternalImage(ctx context.Context) sourceFunc { - return func() (io.ReadCloser, string, error) { - imgURL := a.pl.ExternalImageURL - if imgURL == "" { - return nil, "", nil - } - parsed, err := url.Parse(imgURL) - if err != nil { - return nil, "", err - } - if parsed.Scheme == "http" || parsed.Scheme == "https" { - if !conf.Server.EnableM3UExternalAlbumArt { - return nil, "", nil - } - return fromURL(ctx, parsed) - } - return fromLocalFile(imgURL)() - } -} - -// fromLocalFile returns a sourceFunc that opens the given local path. -// Returns (nil, "", nil) if path is empty — signalling "not found, try next source". -func fromLocalFile(path string) sourceFunc { - return func() (io.ReadCloser, string, error) { - if path == "" { - return nil, "", nil - } - f, err := os.Open(path) - if err != nil { - return nil, "", err - } - return f, path, nil - } -} - -// findPlaylistSidecarPath scans the directory of the playlist file for a sidecar -// image file with the same base name (case-insensitive). Returns empty string if -// no matching image is found or if plsPath is empty. -func findPlaylistSidecarPath(ctx context.Context, plsPath string) string { - if plsPath == "" { - return "" - } - dir := filepath.Dir(plsPath) - base := strings.TrimSuffix(filepath.Base(plsPath), filepath.Ext(plsPath)) - - entries, err := os.ReadDir(dir) - if err != nil { - log.Warn(ctx, "Could not read directory for playlist sidecar", "dir", dir, err) - return "" - } - for _, entry := range entries { - name := entry.Name() - nameBase := strings.TrimSuffix(name, filepath.Ext(name)) - if !entry.IsDir() && strings.EqualFold(nameBase, base) && model.IsImageFile(name) { - return filepath.Join(dir, name) - } - } - return "" -} - -func (a *playlistArtworkReader) fromGeneratedTiledCover(ctx context.Context) sourceFunc { - return func() (io.ReadCloser, string, error) { - tiles, err := a.loadTiles(ctx) - if err != nil { - return nil, "", err - } - r, err := a.createTiledImage(ctx, tiles) - return r, "", err - } -} - -func toAlbumArtworkIDs(albumIDs []string) []model.ArtworkID { - return slice.Map(albumIDs, func(id string) model.ArtworkID { - al := model.Album{ID: id} - return al.CoverArtID() - }) -} - -func (a *playlistArtworkReader) loadTiles(ctx context.Context) ([]image.Image, error) { - tracksRepo := a.a.ds.Playlist(ctx).Tracks(a.pl.ID, false) - albumIds, err := tracksRepo.GetAlbumIDs(model.QueryOptions{Max: 4, Sort: "random()"}) - if err != nil { - log.Error(ctx, "Error getting album IDs for playlist", "id", a.pl.ID, "name", a.pl.Name, err) - return nil, err - } - ids := toAlbumArtworkIDs(albumIds) - - var tiles []image.Image - for _, id := range ids { - r, _, err := fromAlbum(ctx, a.a, id)() - if err == nil { - tile, err := a.createTile(ctx, r) - if err == nil { - tiles = append(tiles, tile) - } - _ = r.Close() - } - if len(tiles) == 4 { - break - } - } - switch len(tiles) { - case 0: - return nil, errors.New("could not find any eligible cover") - case 2: - tiles = append(tiles, tiles[1], tiles[0]) - case 3: - tiles = append(tiles, tiles[0]) - } - return tiles, nil -} - -func (a *playlistArtworkReader) createTile(_ context.Context, r io.ReadCloser) (image.Image, error) { - img, _, err := image.Decode(r) - if err != nil { - return nil, err - } - return fillCenter(img, tileSize/2, tileSize/2), nil -} - -func (a *playlistArtworkReader) createTiledImage(_ context.Context, tiles []image.Image) (io.ReadCloser, error) { - buf := new(bytes.Buffer) - var rgba draw.Image - var err error - if len(tiles) == 4 { - rgba = image.NewRGBA(image.Rectangle{Max: image.Point{X: tileSize - 1, Y: tileSize - 1}}) - draw.Draw(rgba, rect(0), tiles[0], image.Point{}, draw.Src) - draw.Draw(rgba, rect(1), tiles[1], image.Point{}, draw.Src) - draw.Draw(rgba, rect(2), tiles[2], image.Point{}, draw.Src) - draw.Draw(rgba, rect(3), tiles[3], image.Point{}, draw.Src) - err = png.Encode(buf, rgba) - } else { - err = png.Encode(buf, tiles[0]) - } - if err != nil { - return nil, err - } - return io.NopCloser(buf), nil -} - -func rect(pos int) image.Rectangle { - r := image.Rectangle{} - switch pos { - case 1: - r.Min.X = tileSize / 2 - case 2: - r.Min.Y = tileSize / 2 - case 3: - r.Min.X = tileSize / 2 - r.Min.Y = tileSize / 2 - } - r.Max.X = r.Min.X + tileSize/2 - r.Max.Y = r.Min.Y + tileSize/2 - return r -} - -// fillCenter crops the source image from the center and scales it to fill dstW x dstH exactly, -// equivalent to imaging.Fill with Center anchor. -func fillCenter(src image.Image, dstW, dstH int) image.Image { - srcBounds := src.Bounds() - srcW := srcBounds.Dx() - srcH := srcBounds.Dy() - - // Calculate crop rectangle (center crop to match destination aspect ratio) - srcAspect := float64(srcW) / float64(srcH) - dstAspect := float64(dstW) / float64(dstH) - - var cropRect image.Rectangle - if srcAspect > dstAspect { - // Source is wider — crop horizontally - cropW := int(float64(srcH) * dstAspect) - cropX := (srcW - cropW) / 2 - cropRect = image.Rect(srcBounds.Min.X+cropX, srcBounds.Min.Y, srcBounds.Min.X+cropX+cropW, srcBounds.Max.Y) - } else { - // Source is taller — crop vertically - cropH := int(float64(srcW) / dstAspect) - cropY := (srcH - cropH) / 2 - cropRect = image.Rect(srcBounds.Min.X, srcBounds.Min.Y+cropY, srcBounds.Max.X, srcBounds.Min.Y+cropY+cropH) - } - - dst := image.NewNRGBA(image.Rect(0, 0, dstW, dstH)) - xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, cropRect, draw.Src, nil) - return dst -} diff --git a/core/artwork/reader_radio.go b/core/artwork/reader_radio.go deleted file mode 100644 index 22db6e302..000000000 --- a/core/artwork/reader_radio.go +++ /dev/null @@ -1,40 +0,0 @@ -package artwork - -import ( - "context" - "io" - "time" - - "github.com/navidrome/navidrome/model" -) - -type radioArtworkReader struct { - cacheKey - a *artwork - radio model.Radio -} - -func newRadioArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID) (*radioArtworkReader, error) { - r, err := artwork.ds.Radio(ctx).Get(artID.ID) - if err != nil { - return nil, err - } - a := &radioArtworkReader{a: artwork, radio: *r} - a.cacheKey.artID = artID - a.cacheKey.lastUpdate = r.UpdatedAt - return a, nil -} - -func (a *radioArtworkReader) LastUpdated() time.Time { - return a.lastUpdate -} - -func (a *radioArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { - return selectImageReader(ctx, a.artID, - a.fromRadioUploadedImage(), - ) -} - -func (a *radioArtworkReader) fromRadioUploadedImage() sourceFunc { - return fromLocalFile(a.radio.UploadedImagePath()) -} diff --git a/core/artwork/reader_radio_test.go b/core/artwork/reader_radio_test.go deleted file mode 100644 index 37ce1d827..000000000 --- a/core/artwork/reader_radio_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package artwork - -import ( - "context" - "os" - "path/filepath" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("radioArtworkReader", func() { - var ( - tempDir string - reader *radioArtworkReader - ) - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - tempDir = GinkgoT().TempDir() - conf.Server.DataFolder = conf.NewDir(tempDir) - - Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "radio"), 0755)).To(Succeed()) - - reader = &radioArtworkReader{} - }) - - Describe("fromRadioUploadedImage", func() { - When("radio has an uploaded image", func() { - It("returns the uploaded image", func() { - imgPath := filepath.Join(tempDir, "artwork", "radio", "rd-1_test.jpg") - Expect(os.WriteFile(imgPath, []byte("uploaded radio image"), 0600)).To(Succeed()) - - reader.radio = model.Radio{ID: "rd-1", UploadedImage: "rd-1_test.jpg"} - sf := reader.fromRadioUploadedImage() - r, path, err := sf() - Expect(err).ToNot(HaveOccurred()) - Expect(r).ToNot(BeNil()) - Expect(path).To(Equal(imgPath)) - r.Close() - }) - }) - - When("radio has no uploaded image", func() { - It("returns nil reader (falls through)", func() { - reader.radio = model.Radio{ID: "rd-1"} - sf := reader.fromRadioUploadedImage() - r, path, err := sf() - Expect(err).ToNot(HaveOccurred()) - Expect(r).To(BeNil()) - Expect(path).To(BeEmpty()) - }) - }) - }) - - Describe("Reader", func() { - When("radio has an uploaded image", func() { - It("returns the image reader", func() { - imgPath := filepath.Join(tempDir, "artwork", "radio", "rd-1_test.jpg") - Expect(os.WriteFile(imgPath, []byte("uploaded radio image"), 0600)).To(Succeed()) - - reader.radio = model.Radio{ID: "rd-1", UploadedImage: "rd-1_test.jpg"} - reader.cacheKey.artID = model.ArtworkID{Kind: model.KindRadioArtwork, ID: "rd-1"} - r, _, err := reader.Reader(context.Background()) - Expect(err).ToNot(HaveOccurred()) - Expect(r).ToNot(BeNil()) - r.Close() - }) - }) - - When("radio has no uploaded image", func() { - It("returns ErrUnavailable", func() { - reader.radio = model.Radio{ID: "rd-1"} - reader.cacheKey.artID = model.ArtworkID{Kind: model.KindRadioArtwork, ID: "rd-1"} - r, _, err := reader.Reader(context.Background()) - Expect(err).To(MatchError(ErrUnavailable)) - Expect(r).To(BeNil()) - }) - }) - }) -}) diff --git a/core/artwork/reader_resized.go b/core/artwork/reader_resized.go deleted file mode 100644 index 7d920e790..000000000 --- a/core/artwork/reader_resized.go +++ /dev/null @@ -1,87 +0,0 @@ -package artwork - -import ( - "context" - "fmt" - "io" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" -) - -type resizedArtworkReader struct { - artID model.ArtworkID - cacheKey string - lastUpdate time.Time - size int - square bool - a *artwork -} - -func resizedFromOriginal(ctx context.Context, a *artwork, artID model.ArtworkID, size int, square bool) (*resizedArtworkReader, error) { - r := &resizedArtworkReader{a: a} - r.artID = artID - r.size = size - r.square = square - - // Get lastUpdated and cacheKey from original artwork - original, err := a.getArtworkReader(ctx, artID, 0, false) - if err != nil { - return nil, err - } - r.cacheKey = original.Key() - r.lastUpdate = original.LastUpdated() - return r, nil -} - -func (a *resizedArtworkReader) Key() string { - baseKey := fmt.Sprintf("%s.%d", a.cacheKey, a.size) - if a.square { - return baseKey + ".square" - } - return fmt.Sprintf("%s.%d", baseKey, conf.Server.CoverArtQuality) -} - -func (a *resizedArtworkReader) LastUpdated() time.Time { - return a.lastUpdate -} - -func (a *resizedArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { - // Get artwork in original size, possibly from cache - orig, _, err := a.a.Get(ctx, a.artID, 0, false) - if err != nil { - return nil, "", err - } - defer orig.Close() - - resized, origSize, err := a.resizeImage(ctx, orig) - if resized == nil { - log.Trace(ctx, "Image smaller than requested size", "artID", a.artID, "original", origSize, "resized", a.size, "square", a.square) - } else { - log.Trace(ctx, "Resizing artwork", "artID", a.artID, "original", origSize, "resized", a.size, "square", a.square) - } - if err != nil { - log.Warn(ctx, "Could not resize image. Will return image as is", "artID", a.artID, "size", a.size, "square", a.square, err) - } - if err != nil || resized == nil { - // if we couldn't resize the image, return the original - orig, _, err = a.a.Get(ctx, a.artID, 0, false) - return orig, "", err - } - // Preserve ReadCloser semantics if the resized reader already supports Close - // (e.g., ffmpeg pipe), otherwise wrap with NopCloser - if rc, ok := resized.(io.ReadCloser); ok { - return rc, fmt.Sprintf("%s@%d", a.artID, a.size), nil - } - return io.NopCloser(resized), fmt.Sprintf("%s@%d", a.artID, a.size), nil -} - -func (a *resizedArtworkReader) resizeImage(ctx context.Context, reader io.Reader) (io.Reader, int, error) { - data, err := io.ReadAll(reader) - if err != nil { - return nil, 0, fmt.Errorf("reading image data: %w", err) - } - return resizeImageData(ctx, a.a.ffmpeg, data, a.size, a.square) -} diff --git a/core/artwork/reader_resized_test.go b/core/artwork/reader_resized_test.go deleted file mode 100644 index 7c14f5e44..000000000 --- a/core/artwork/reader_resized_test.go +++ /dev/null @@ -1,176 +0,0 @@ -package artwork - -import ( - "bytes" - "context" - "errors" - "io" - - "github.com/navidrome/navidrome/core/ffmpeg" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("resizeImage", func() { - var mockFF *tests.MockFFmpeg - var r *resizedArtworkReader - - BeforeEach(func() { - mockFF = tests.NewMockFFmpeg("converted-animated-data") - r = &resizedArtworkReader{ - size: 300, - square: false, - a: &artwork{ffmpeg: mockFF}, - } - }) - - Describe("animated GIF handling", func() { - It("converts animated GIF via ffmpeg when available", func() { - data := createAnimatedGIF(3) - result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) - Expect(err).ToNot(HaveOccurred()) - Expect(result).ToNot(BeNil()) - - // Should have been processed by ffmpeg (mock returns "converted-animated-data") - output, err := io.ReadAll(result) - Expect(err).ToNot(HaveOccurred()) - Expect(output).To(Equal(data)) // MockFFmpeg echoes input back - }) - - It("falls back to static resize when ffmpeg fails for animated GIF", func() { - mockFF.Error = errors.New("ffmpeg failed") - // Use size smaller than image so static resize actually produces output - r.size = 1 - data := createAnimatedGIF(3) - result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) - // Should fall through to static resize successfully (no ffmpeg error propagated) - Expect(err).ToNot(HaveOccurred()) - Expect(result).ToNot(BeNil()) - - // Verify it's a static image (WebP encoded), not the ffmpeg error - output, err := io.ReadAll(result) - Expect(err).ToNot(HaveOccurred()) - Expect(len(output)).To(BeNumerically(">", 0)) - }) - - It("preserves animation for square thumbnails with animated GIF", func() { - r.square = true - data := createAnimatedGIF(3) - result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) - Expect(err).ToNot(HaveOccurred()) - Expect(result).ToNot(BeNil()) - - // Should have been processed by ffmpeg (mock returns input data) - output, err := io.ReadAll(result) - Expect(err).ToNot(HaveOccurred()) - Expect(output).To(Equal(data)) - }) - }) - - Describe("animated WebP handling", func() { - It("returns animated WebP data as-is when not square", func() { - data := createAnimatedWebPBytes() - result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) - Expect(err).ToNot(HaveOccurred()) - Expect(result).ToNot(BeNil()) - - // Should return original data unchanged - output, err := io.ReadAll(result) - Expect(err).ToNot(HaveOccurred()) - Expect(output).To(Equal(data)) - }) - - It("preserves animated WebP for square thumbnails", func() { - r.square = true - data := createAnimatedWebPBytes() - result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) - Expect(err).ToNot(HaveOccurred()) - Expect(result).ToNot(BeNil()) - - // Should return original data unchanged - output, err := io.ReadAll(result) - Expect(err).ToNot(HaveOccurred()) - Expect(output).To(Equal(data)) - }) - }) - - Describe("animated PNG handling", func() { - It("returns animated PNG data as-is when not square", func() { - data := createAPNGBytes() - result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) - Expect(err).ToNot(HaveOccurred()) - Expect(result).ToNot(BeNil()) - - // Should return original data unchanged - output, err := io.ReadAll(result) - Expect(err).ToNot(HaveOccurred()) - Expect(output).To(Equal(data)) - }) - - It("preserves animated PNG for square thumbnails", func() { - r.square = true - data := createAPNGBytes() - result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) - Expect(err).ToNot(HaveOccurred()) - Expect(result).ToNot(BeNil()) - - // Should return original data unchanged - output, err := io.ReadAll(result) - Expect(err).ToNot(HaveOccurred()) - Expect(output).To(Equal(data)) - }) - }) - - Describe("static image handling", func() { - It("resizes a static PNG normally", func() { - data := createStaticPNGBytes() - result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) - // Static PNG is 2x2, size 300 is larger, so should return nil (no upscale) - Expect(err).ToNot(HaveOccurred()) - Expect(result).To(BeNil()) - }) - }) - - Describe("ReadCloser preservation", func() { - It("preserves Close semantics from ffmpeg ReadCloser", func() { - // Create a trackable ReadCloser - tracker := &closeTracker{Reader: bytes.NewReader([]byte("test data"))} - mockFF2 := &mockFFmpegWithCloser{tracker: tracker} - r.a = &artwork{ffmpeg: mockFF2} - - data := createAnimatedGIF(3) - result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) - Expect(err).ToNot(HaveOccurred()) - - // The result should be an io.ReadCloser (the tracker) - rc, ok := result.(io.ReadCloser) - Expect(ok).To(BeTrue()) - Expect(rc.Close()).ToNot(HaveOccurred()) - Expect(tracker.closed).To(BeTrue()) - }) - }) -}) - -// closeTracker is an io.ReadCloser that tracks whether Close was called. -type closeTracker struct { - io.Reader - closed bool -} - -func (c *closeTracker) Close() error { - c.closed = true - return nil -} - -// mockFFmpegWithCloser is a minimal FFmpeg mock that returns a specific ReadCloser -// for ConvertAnimatedImage, allowing us to verify Close propagation. -type mockFFmpegWithCloser struct { - ffmpeg.FFmpeg - tracker *closeTracker -} - -func (m *mockFFmpegWithCloser) IsAvailable() bool { return true } -func (m *mockFFmpegWithCloser) ConvertAnimatedImage(_ context.Context, _ io.Reader, _ int, _ int) (io.ReadCloser, error) { - return m.tracker, nil -} diff --git a/core/artwork/serving.go b/core/artwork/serving.go index 7bfc46c81..a2f9c57ef 100644 --- a/core/artwork/serving.go +++ b/core/artwork/serving.go @@ -18,6 +18,8 @@ import ( "github.com/navidrome/navidrome/utils/cache" ) +var ErrUnavailable = errors.New("artwork unavailable") + // errStaleSource signals that a backing file's mtime no longer matches the state // row's RefMtime: the stored hash may be stale, so the load is aborted (dangling). var errStaleSource = errors.New("artwork: source file changed since resolution") @@ -254,7 +256,7 @@ func (s *service) provisionalEmbedded(ctx context.Context, artID model.ArtworkID // serveDisc serves disc-level artwork as a pure provisional read-through: no state rows, // no enqueue. It tries the disc-folder selection chain and falls back to the album cover. func (s *service) serveDisc(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) { - dr, err := newDiscArtworkReader(ctx, &artwork{ds: s.ds, ffmpeg: s.ffmpeg}, artID) + dr, err := newDiscArtworkReader(ctx, s.ds, artID) if err != nil { return nil, err } diff --git a/core/artwork/sources.go b/core/artwork/sources.go index 04a9257fb..9e9916e3e 100644 --- a/core/artwork/sources.go +++ b/core/artwork/sources.go @@ -16,11 +16,9 @@ import ( "time" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/resources" "go.senan.xyz/taglib" ) @@ -171,44 +169,6 @@ type readCloser struct { io.Closer } -func fromAlbum(ctx context.Context, a *artwork, id model.ArtworkID) sourceFunc { - return func() (io.ReadCloser, string, error) { - r, _, err := a.Get(ctx, id, 0, false) - if err != nil { - return nil, "", err - } - return r, id.String(), nil - } -} - -func fromAlbumPlaceholder() sourceFunc { - return func() (io.ReadCloser, string, error) { - r, _ := resources.FS().Open(consts.PlaceholderAlbumArt) - return r, consts.PlaceholderAlbumArt, nil - } -} -func fromArtistExternalSource(ctx context.Context, ar model.Artist, provider external.Provider) sourceFunc { - return func() (io.ReadCloser, string, error) { - imageUrl, err := provider.ArtistImage(ctx, ar.ID) - if err != nil { - return nil, "", err - } - - return fromURL(ctx, imageUrl) - } -} - -func fromAlbumExternalSource(ctx context.Context, al model.Album, provider external.Provider) sourceFunc { - return func() (io.ReadCloser, string, error) { - imageUrl, err := provider.AlbumImage(ctx, al.ID) - if err != nil { - return nil, "", err - } - - return fromURL(ctx, imageUrl) - } -} - func fromURL(ctx context.Context, imageUrl *url.URL) (io.ReadCloser, string, error) { hc := http.Client{Timeout: 5 * time.Second} req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageUrl.String(), nil) diff --git a/core/artwork/testhelpers_test.go b/core/artwork/testhelpers_test.go new file mode 100644 index 000000000..97f69136a --- /dev/null +++ b/core/artwork/testhelpers_test.go @@ -0,0 +1,37 @@ +package artwork + +import ( + "github.com/navidrome/navidrome/model" +) + +type fakeFolderRepo struct { + model.FolderRepository + result []model.Folder + parentResult *model.Folder + getErr error + getCallCount int + err error + // hasOtherAudio is returned by HasAudioOutsideFolders (the album-root + // check). False means the parent qualifies as an album root. + hasOtherAudio bool + otherAudioErr error +} + +func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) { + return f.result, f.err +} + +func (f *fakeFolderRepo) HasAudioOutsideFolders(model.Folder, []string) (bool, error) { + return f.hasOtherAudio, f.otherAudioErr +} + +func (f *fakeFolderRepo) Get(id string) (*model.Folder, error) { + f.getCallCount++ + if f.getErr != nil { + return nil, f.getErr + } + if f.parentResult != nil { + return f.parentResult, nil + } + return nil, model.ErrNotFound +} diff --git a/core/artwork/wire_providers.go b/core/artwork/wire_providers.go index 2c93c27e9..8efb30da2 100644 --- a/core/artwork/wire_providers.go +++ b/core/artwork/wire_providers.go @@ -5,10 +5,8 @@ import ( ) var Set = wire.NewSet( - NewArtwork, NewService, GetImageCache, - NewCacheWarmer, NewWorker, ProvideImageStore, ) diff --git a/core/external/provider.go b/core/external/provider.go index 42da7eb45..0a61947a5 100644 --- a/core/external/provider.go +++ b/core/external/provider.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "net/url" "sort" "strings" "time" @@ -35,10 +34,6 @@ type Provider interface { UpdateArtistInfo(ctx context.Context, id string, count int, includeNotPresent bool) (*model.Artist, error) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) TopSongs(ctx context.Context, artist string, count int) (model.MediaFiles, error) - ArtistImage(ctx context.Context, id string) (*url.URL, error) - // ArtistImageResult is like ArtistImage but reports a transient agent failure as a real error, not ErrNotFound. - ArtistImageResult(ctx context.Context, id string) (*url.URL, error) - AlbumImage(ctx context.Context, id string) (*url.URL, error) } type provider struct { @@ -372,94 +367,6 @@ func (e *provider) similarSongsFallback(ctx context.Context, id string, count in return similarSongs, nil } -func (e *provider) ArtistImage(ctx context.Context, id string) (*url.URL, error) { - u, _, err := e.artistImage(ctx, id) - return u, err -} - -// ArtistImageResult is like ArtistImage but surfaces a transient agent failure as the -// real error, so an agent outage is not mistaken for a definitive no-image (ErrNotFound). -func (e *provider) ArtistImageResult(ctx context.Context, id string) (*url.URL, error) { - u, agentErr, err := e.artistImage(ctx, id) - if agentErr != nil && errors.Is(err, model.ErrNotFound) { - return nil, agentErr - } - return u, err -} - -// artistImage returns the agent error (agentErr) separately from the caller-facing err, -// so ArtistImageResult can tell "agent errored" apart from "definitively no image". -func (e *provider) artistImage(ctx context.Context, id string) (u *url.URL, agentErr error, err error) { - artist, err := e.getArtist(ctx, id) - if err != nil { - return nil, nil, err - } - - imageUrl := artist.ArtistImageUrl() - if imageUrl == "" { - // No cached URL — must fetch from external source synchronously - agentErr = e.callGetImage(ctx, e.ag, &artist) - if utils.IsCtxDone(ctx) { - log.Warn(ctx, "ArtistImage call canceled", ctx.Err()) - return nil, agentErr, ctx.Err() - } - imageUrl = artist.ArtistImageUrl() - } else { - // If cached info is expired, enqueue a background refresh so that config changes - // (e.g. disabling an agent) take effect without waiting for a full artist info refresh. - updatedAt := V(artist.ExternalInfoUpdatedAt) - if !updatedAt.IsZero() && time.Since(updatedAt) > conf.Server.DevArtistInfoTimeToLive { - log.Debug(ctx, "Artist image info expired, enqueuing background refresh", "artist", artist.Name(), "updatedAt", updatedAt) - e.artistQueue.enqueue(&artist) - } - } - - if imageUrl == "" { - return nil, agentErr, model.ErrNotFound - } - u, err = url.Parse(imageUrl) - return u, agentErr, err -} - -func (e *provider) AlbumImage(ctx context.Context, id string) (*url.URL, error) { - album, err := e.getAlbum(ctx, id) - if err != nil { - return nil, err - } - - albumName := album.Name() - images, err := e.ag.GetAlbumImages(ctx, albumName, album.AlbumArtist, album.MbzAlbumID) - if err != nil { - switch { - case errors.Is(err, agents.ErrNotFound): - log.Trace(ctx, "Album not found in agent", "albumID", id, "name", albumName, "artist", album.AlbumArtist) - return nil, model.ErrNotFound - case errors.Is(err, context.Canceled): - log.Debug(ctx, "GetAlbumImages call canceled", err) - default: - log.Warn(ctx, "Error getting album images from agent", "albumID", id, "name", albumName, "artist", album.AlbumArtist, err) - } - return nil, err - } - - if len(images) == 0 { - log.Warn(ctx, "Agent returned no images without error", "albumID", id, "name", albumName, "artist", album.AlbumArtist) - return nil, model.ErrNotFound - } - - // Return the biggest image - var img agents.ExternalImage - for _, i := range images { - if img.Size <= i.Size { - img = i - } - } - if img.URL == "" { - return nil, model.ErrNotFound - } - return url.Parse(img.URL) -} - func (e *provider) TopSongs(ctx context.Context, artistName string, count int) (model.MediaFiles, error) { artist, err := e.findArtistByName(ctx, artistName) if err != nil { diff --git a/core/external/provider_albumimage_test.go b/core/external/provider_albumimage_test.go deleted file mode 100644 index e801b7cce..000000000 --- a/core/external/provider_albumimage_test.go +++ /dev/null @@ -1,365 +0,0 @@ -package external_test - -import ( - "context" - "errors" - "net/url" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/agents" - . "github.com/navidrome/navidrome/core/external" - "github.com/navidrome/navidrome/core/matcher" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "github.com/stretchr/testify/mock" -) - -var _ = Describe("Provider - AlbumImage", func() { - var ds *tests.MockDataStore - var provider Provider - var mockArtistRepo *mockArtistRepo - var mockAlbumRepo *mockAlbumRepo - var mockMediaFileRepo *mockMediaFileRepo - var mockAlbumAgent *mockAlbumInfoAgent - var ctx context.Context - - BeforeEach(func() { - ctx = GinkgoT().Context() - DeferCleanup(configtest.SetupConfig()) - conf.Server.Agents = "mockAlbum" // Configure mock agent - - mockArtistRepo = newMockArtistRepo() - mockAlbumRepo = newMockAlbumRepo() - mockMediaFileRepo = newMockMediaFileRepo() - - ds = &tests.MockDataStore{ - MockedArtist: mockArtistRepo, - MockedAlbum: mockAlbumRepo, - MockedMediaFile: mockMediaFileRepo, - } - - mockAlbumAgent = newMockAlbumInfoAgent() - - agentsCombined := &mockAgents{albumInfoAgent: mockAlbumAgent} - provider = NewProvider(ds, agentsCombined, matcher.New(ds)) - - // Default mocks - // Mocks for GetEntityByID sequence (initial failed lookups) - mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() - mockArtistRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once() - mockAlbumRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once() - - // Default mock for non-existent entities - Use Maybe() for flexibility - mockArtistRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe() - mockAlbumRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe() - mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe() - }) - - It("returns the largest image URL when successful", func() { - // Arrange - mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence - mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() - // Explicitly mock agent call for this test - mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", ""). - Return([]agents.ExternalImage{ - {URL: "http://example.com/large.jpg", Size: 1000}, - {URL: "http://example.com/medium.jpg", Size: 500}, - {URL: "http://example.com/small.jpg", Size: 200}, - }, nil).Once() - - expectedURL, _ := url.Parse("http://example.com/large.jpg") - imgURL, err := provider.AlbumImage(ctx, "album-1") - - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") // From GetEntityByID - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") - mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1") // Artist lookup no longer happens in getAlbum - mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist name - }) - - It("returns ErrNotFound if the album is not found in the DB", func() { - // Arrange: Explicitly expect the full GetEntityByID sequence for "not-found" - mockArtistRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once() - mockAlbumRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once() - mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once() - - imgURL, err := provider.AlbumImage(ctx, "not-found") - - Expect(err).To(MatchError("data not found")) - Expect(imgURL).To(BeNil()) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found") - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "not-found") - mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "not-found") - mockAlbumAgent.AssertNotCalled(GinkgoT(), "GetAlbumImages", mock.Anything, mock.Anything, mock.Anything) - }) - - It("returns the agent error if the agent fails", func() { - // Arrange - mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence - mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() - - agentErr := errors.New("agent failure") - // Explicitly mock agent call for this test - mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").Return(nil, agentErr).Once() // Expect empty artist - - imgURL, err := provider.AlbumImage(ctx, "album-1") - - Expect(err).To(MatchError("agent failure")) - Expect(imgURL).To(BeNil()) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") - mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1") - mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist - }) - - It("returns ErrNotFound if the agent returns ErrNotFound", func() { - // Arrange - mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence - mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() - - // Explicitly mock agent call for this test - mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").Return(nil, agents.ErrNotFound).Once() // Expect empty artist - - imgURL, err := provider.AlbumImage(ctx, "album-1") - - Expect(err).To(MatchError("data not found")) - Expect(imgURL).To(BeNil()) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") - mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist - }) - - It("returns ErrNotFound if the agent returns no images", func() { - // Arrange - mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence - mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() - - // Explicitly mock agent call for this test - mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", ""). - Return([]agents.ExternalImage{}, nil).Once() // Expect empty artist - - imgURL, err := provider.AlbumImage(ctx, "album-1") - - Expect(err).To(MatchError("data not found")) - Expect(imgURL).To(BeNil()) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") - mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist - }) - - It("returns context error if context is canceled", func() { - // Arrange - cctx, cancelCtx := context.WithCancel(ctx) - // Mock the necessary DB calls *before* canceling the context - mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() - mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() - // Expect the agent call even if context is cancelled, returning the context error - mockAlbumAgent.On("GetAlbumImages", cctx, "Album One", "", "").Return(nil, context.Canceled).Once() - // Cancel the context *before* calling the function under test - cancelCtx() - - imgURL, err := provider.AlbumImage(cctx, "album-1") - - Expect(err).To(MatchError("context canceled")) - Expect(imgURL).To(BeNil()) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") - // Agent should now be called, verify this expectation - mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", cctx, "Album One", "", "") - }) - - It("derives album ID from MediaFile ID", func() { - // Arrange: Mock full GetEntityByID for "mf-1" and recursive "album-1" - mockArtistRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once() - mockAlbumRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once() - mockMediaFileRepo.On("Get", "mf-1").Return(&model.MediaFile{ID: "mf-1", Title: "Track One", ArtistID: "artist-1", AlbumID: "album-1"}, nil).Once() - mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() - mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() - - // Explicitly mock agent call for this test - mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", ""). - Return([]agents.ExternalImage{ - {URL: "http://example.com/large.jpg", Size: 1000}, - {URL: "http://example.com/medium.jpg", Size: 500}, - {URL: "http://example.com/small.jpg", Size: 200}, - }, nil).Once() - - expectedURL, _ := url.Parse("http://example.com/large.jpg") - imgURL, err := provider.AlbumImage(ctx, "mf-1") - - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "mf-1") - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "mf-1") - mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "mf-1") - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") - mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1") - mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") - }) - - It("handles different image orders from agent", func() { - // Arrange - mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence - mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() - // Explicitly mock agent call for this test - mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", ""). - Return([]agents.ExternalImage{ - {URL: "http://example.com/small.jpg", Size: 200}, - {URL: "http://example.com/large.jpg", Size: 1000}, - {URL: "http://example.com/medium.jpg", Size: 500}, - }, nil).Once() - - expectedURL, _ := url.Parse("http://example.com/large.jpg") - imgURL, err := provider.AlbumImage(ctx, "album-1") - - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) // Should still pick the largest - mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") - }) - - It("handles agent returning only one image", func() { - // Arrange - mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence - mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() - // Explicitly mock agent call for this test - mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", ""). - Return([]agents.ExternalImage{ - {URL: "http://example.com/single.jpg", Size: 700}, - }, nil).Once() - - expectedURL, _ := url.Parse("http://example.com/single.jpg") - imgURL, err := provider.AlbumImage(ctx, "album-1") - - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") - }) - - It("returns ErrNotFound if deriving album ID fails", func() { - // Arrange: Mock full GetEntityByID for "mf-no-album" and recursive "not-found" - mockArtistRepo.On("Get", "mf-no-album").Return(nil, model.ErrNotFound).Once() - mockAlbumRepo.On("Get", "mf-no-album").Return(nil, model.ErrNotFound).Once() - mockMediaFileRepo.On("Get", "mf-no-album").Return(&model.MediaFile{ID: "mf-no-album", Title: "Track No Album", ArtistID: "artist-1", AlbumID: "not-found"}, nil).Once() - mockArtistRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once() - mockAlbumRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once() - mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once() - - imgURL, err := provider.AlbumImage(ctx, "mf-no-album") - - Expect(err).To(MatchError("data not found")) - Expect(imgURL).To(BeNil()) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "mf-no-album") - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "mf-no-album") - mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "mf-no-album") - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found") - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "not-found") - mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "not-found") - mockAlbumAgent.AssertNotCalled(GinkgoT(), "GetAlbumImages", mock.Anything, mock.Anything, mock.Anything) - }) - - Context("Unicode handling in album names", func() { - var albumWithEnDash *model.Album - var expectedURL *url.URL - - const ( - originalAlbumName = "Raising Hell–Deluxe" // Album name with en dash - normalizedAlbumName = "Raising Hell-Deluxe" // Normalized version with hyphen - ) - - BeforeEach(func() { - // Test with en dash (–) in album name - albumWithEnDash = &model.Album{ID: "album-endash", Name: originalAlbumName, AlbumArtistID: "artist-1"} - mockArtistRepo.Mock = mock.Mock{} // Reset default expectations - mockAlbumRepo.Mock = mock.Mock{} // Reset default expectations - mockArtistRepo.On("Get", "album-endash").Return(nil, model.ErrNotFound).Once() - mockAlbumRepo.On("Get", "album-endash").Return(albumWithEnDash, nil).Once() - - expectedURL, _ = url.Parse("http://example.com/album.jpg") - - // Mock the album agent to return an image for the album - mockAlbumAgent.On("GetAlbumImages", ctx, mock.AnythingOfType("string"), "", ""). - Return([]agents.ExternalImage{ - {URL: "http://example.com/album.jpg", Size: 1000}, - }, nil).Once() - }) - - When("DevPreserveUnicodeInExternalCalls is true", func() { - BeforeEach(func() { - conf.Server.DevPreserveUnicodeInExternalCalls = true - }) - - It("preserves Unicode characters in album names", func() { - // Act - imgURL, err := provider.AlbumImage(ctx, "album-endash") - - // Assert - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-endash") - // This is the key assertion: ensure the original Unicode name is used - mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, originalAlbumName, "", "") - }) - }) - - When("DevPreserveUnicodeInExternalCalls is false", func() { - BeforeEach(func() { - conf.Server.DevPreserveUnicodeInExternalCalls = false - }) - - It("normalizes Unicode characters", func() { - // Act - imgURL, err := provider.AlbumImage(ctx, "album-endash") - - // Assert - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-endash") - // This assertion ensures the normalized name is used (en dash → hyphen) - mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, normalizedAlbumName, "", "") - }) - }) - }) -}) - -// mockAlbumInfoAgent implementation -type mockAlbumInfoAgent struct { - mock.Mock - agents.AlbumInfoRetriever - agents.AlbumImageRetriever -} - -func newMockAlbumInfoAgent() *mockAlbumInfoAgent { - m := new(mockAlbumInfoAgent) - m.On("AgentName").Return("mockAlbum").Maybe() - return m -} - -func (m *mockAlbumInfoAgent) AgentName() string { - args := m.Called() - return args.String(0) -} - -func (m *mockAlbumInfoAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) { - args := m.Called(ctx, name, artist, mbid) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).(*agents.AlbumInfo), args.Error(1) -} - -func (m *mockAlbumInfoAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) { - args := m.Called(ctx, name, artist, mbid) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).([]agents.ExternalImage), args.Error(1) -} - -// Ensure mockAgent implements the interfaces -var _ agents.AlbumInfoRetriever = (*mockAlbumInfoAgent)(nil) -var _ agents.AlbumImageRetriever = (*mockAlbumInfoAgent)(nil) diff --git a/core/external/provider_artistimage_test.go b/core/external/provider_artistimage_test.go deleted file mode 100644 index 621aac574..000000000 --- a/core/external/provider_artistimage_test.go +++ /dev/null @@ -1,469 +0,0 @@ -package external_test - -import ( - "bytes" - "context" - "errors" - "net/url" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/agents" - . "github.com/navidrome/navidrome/core/external" - "github.com/navidrome/navidrome/core/matcher" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "github.com/stretchr/testify/mock" -) - -var _ = Describe("Provider - ArtistImage", func() { - var ds *tests.MockDataStore - var provider Provider - var mockArtistRepo *mockArtistRepo - var mockAlbumRepo *mockAlbumRepo - var mockMediaFileRepo *mockMediaFileRepo - var mockImageAgent *mockArtistImageAgent - var agentsCombined *mockAgents - var ctx context.Context - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.Agents = "mockImage" // Configure only the mock agent - ctx = GinkgoT().Context() - - mockArtistRepo = newMockArtistRepo() - mockAlbumRepo = newMockAlbumRepo() - mockMediaFileRepo = newMockMediaFileRepo() - - ds = &tests.MockDataStore{ - MockedArtist: mockArtistRepo, - MockedAlbum: mockAlbumRepo, - MockedMediaFile: mockMediaFileRepo, - } - - mockImageAgent = newMockArtistImageAgent() - - // Use the mockAgents from helper, setting the specific agent - agentsCombined = &mockAgents{ - imageAgent: mockImageAgent, - } - - provider = NewProvider(ds, agentsCombined, matcher.New(ds)) - - // Default mocks for successful Get calls - mockArtistRepo.On("Get", "artist-1").Return(&model.Artist{ID: "artist-1", Name: "Artist One"}, nil).Maybe() - mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Maybe() - mockMediaFileRepo.On("Get", "mf-1").Return(&model.MediaFile{ID: "mf-1", Title: "Track One", ArtistID: "artist-1"}, nil).Maybe() - // Default mock for non-existent entities - mockArtistRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe() - mockAlbumRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe() - mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe() - - // Default successful image agent response - mockImageAgent.On("GetArtistImages", mock.Anything, "artist-1", "Artist One", ""). - Return([]agents.ExternalImage{ - {URL: "http://example.com/large.jpg", Size: 1000}, - {URL: "http://example.com/medium.jpg", Size: 500}, - {URL: "http://example.com/small.jpg", Size: 200}, - }, nil).Maybe() - }) - - AfterEach(func() { - mockArtistRepo.AssertExpectations(GinkgoT()) - mockAlbumRepo.AssertExpectations(GinkgoT()) - mockMediaFileRepo.AssertExpectations(GinkgoT()) - mockImageAgent.AssertExpectations(GinkgoT()) - }) - - It("returns the largest image URL when successful", func() { - // Arrange - expectedURL, _ := url.Parse("http://example.com/large.jpg") - - // Act - imgURL, err := provider.ArtistImage(ctx, "artist-1") - - // Assert - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") - mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") - }) - - It("returns ErrNotFound if the artist is not found in the DB", func() { - // Arrange - - // Act - imgURL, err := provider.ArtistImage(ctx, "not-found") - - // Assert - Expect(err).To(MatchError(model.ErrNotFound)) - Expect(imgURL).To(BeNil()) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found") - mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, mock.Anything, mock.Anything, mock.Anything) - }) - - It("returns the agent error if the agent fails", func() { - // Arrange - agentErr := errors.New("agent failure") - mockImageAgent.Mock = mock.Mock{} // Reset default expectation - mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").Return(nil, agentErr).Once() - - // Act - imgURL, err := provider.ArtistImage(ctx, "artist-1") - - // Assert - Expect(err).To(MatchError(model.ErrNotFound)) // Corrected Expectation: The provider maps agent errors (other than canceled) to ErrNotFound if no image was found/populated - Expect(imgURL).To(BeNil()) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") - mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") - }) - - It("returns ErrNotFound if the agent returns ErrNotFound", func() { - // Arrange - mockImageAgent.Mock = mock.Mock{} // Reset default expectation - mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").Return(nil, agents.ErrNotFound).Once() - - // Act - imgURL, err := provider.ArtistImage(ctx, "artist-1") - - // Assert - Expect(err).To(MatchError(model.ErrNotFound)) - Expect(imgURL).To(BeNil()) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") - mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") - }) - - It("returns ErrNotFound if the agent returns no images", func() { - // Arrange - mockImageAgent.Mock = mock.Mock{} // Reset default expectation - mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").Return([]agents.ExternalImage{}, nil).Once() - - // Act - imgURL, err := provider.ArtistImage(ctx, "artist-1") - - // Assert - Expect(err).To(MatchError(model.ErrNotFound)) // Implementation maps empty result to ErrNotFound - Expect(imgURL).To(BeNil()) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") - mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") - }) - - It("returns context error if context is canceled before agent call", func() { - // Arrange - cctx, cancelCtx := context.WithCancel(context.Background()) - mockArtistRepo.Mock = mock.Mock{} // Reset default expectation for artist repo as well - mockArtistRepo.On("Get", "artist-1").Return(&model.Artist{ID: "artist-1", Name: "Artist One"}, nil).Run(func(args mock.Arguments) { - cancelCtx() // Cancel context *during* the DB call simulation - }).Once() - - // Act - imgURL, err := provider.ArtistImage(cctx, "artist-1") - - // Assert - Expect(err).To(MatchError(context.Canceled)) - Expect(imgURL).To(BeNil()) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") - }) - - It("derives artist ID from MediaFile ID", func() { - // Arrange: Add mocks for the initial GetEntityByID lookups - mockArtistRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once() - mockAlbumRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once() - // Default mocks for MediaFileRepo.Get("mf-1") and ArtistRepo.Get("artist-1") handle the rest - expectedURL, _ := url.Parse("http://example.com/large.jpg") - - // Act - imgURL, err := provider.ArtistImage(ctx, "mf-1") - - // Assert - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "mf-1") // GetEntityByID sequence - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "mf-1") // GetEntityByID sequence - mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "mf-1") - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") // Should be called after getting MF - mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") - }) - - It("derives artist ID from Album ID", func() { - // Arrange: Add mock for the initial GetEntityByID lookup - mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() - // Default mocks for AlbumRepo.Get("album-1") and ArtistRepo.Get("artist-1") handle the rest - expectedURL, _ := url.Parse("http://example.com/large.jpg") - - // Act - imgURL, err := provider.ArtistImage(ctx, "album-1") - - // Assert - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") // GetEntityByID sequence - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") // Should be called after getting Album - mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") - }) - - It("returns ErrNotFound if derived artist is not found", func() { - // Arrange - // Add mocks for the initial GetEntityByID lookups - mockArtistRepo.On("Get", "mf-bad-artist").Return(nil, model.ErrNotFound).Once() - mockAlbumRepo.On("Get", "mf-bad-artist").Return(nil, model.ErrNotFound).Once() - mockMediaFileRepo.On("Get", "mf-bad-artist").Return(&model.MediaFile{ID: "mf-bad-artist", ArtistID: "not-found"}, nil).Once() - // Add expectation for the recursive GetEntityByID call for the MediaFileRepo - mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe() - // The default mocks for ArtistRepo/AlbumRepo handle the final "not-found" lookups - - // Act - imgURL, err := provider.ArtistImage(ctx, "mf-bad-artist") - - // Assert - Expect(err).To(MatchError(model.ErrNotFound)) - Expect(imgURL).To(BeNil()) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "mf-bad-artist") // GetEntityByID sequence - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "mf-bad-artist") // GetEntityByID sequence - mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "mf-bad-artist") - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found") - mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, mock.Anything, mock.Anything, mock.Anything) - }) - - It("handles different image orders from agent", func() { - // Arrange - mockImageAgent.Mock = mock.Mock{} // Reset default expectation - mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", ""). - Return([]agents.ExternalImage{ - {URL: "http://example.com/small.jpg", Size: 200}, - {URL: "http://example.com/large.jpg", Size: 1000}, - {URL: "http://example.com/medium.jpg", Size: 500}, - }, nil).Once() - expectedURL, _ := url.Parse("http://example.com/large.jpg") - - // Act - imgURL, err := provider.ArtistImage(ctx, "artist-1") - - // Assert - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) // Still picks the largest - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") - mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") - }) - - It("handles agent returning only one image", func() { - // Arrange - mockImageAgent.Mock = mock.Mock{} // Reset default expectation - mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", ""). - Return([]agents.ExternalImage{ - {URL: "http://example.com/medium.jpg", Size: 500}, - }, nil).Once() - expectedURL, _ := url.Parse("http://example.com/medium.jpg") - - // Act - imgURL, err := provider.ArtistImage(ctx, "artist-1") - - // Assert - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") - mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") - }) - - It("returns cached URL and does not call agent when info is not expired", func() { - // Arrange: artist has a cached image URL with recent ExternalInfoUpdatedAt - cachedArtist := &model.Artist{ - ID: "artist-cached", - Name: "Cached Artist", - LargeImageUrl: "http://example.com/cached-large.jpg", - ExternalInfoUpdatedAt: new(time.Now().Add(-1 * time.Minute)), - } - mockArtistRepo.On("Get", "artist-cached").Return(cachedArtist, nil).Maybe() - expectedURL, _ := url.Parse("http://example.com/cached-large.jpg") - - // Capture log output - var logBuf bytes.Buffer - log.SetOutput(&logBuf) - defer log.SetOutput(GinkgoWriter) - log.SetLevel(log.LevelDebug) - - // Act - imgURL, err := provider.ArtistImage(ctx, "artist-cached") - - // Assert - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, "artist-cached", mock.Anything, mock.Anything) - - // Assert: background refresh was NOT enqueued - Expect(logBuf.String()).ToNot(ContainSubstring("Artist image info expired, enqueuing background refresh")) - - }) - - It("returns stale URL and enqueues refresh when info is expired", func() { - // Arrange - conf.Server.DevArtistInfoTimeToLive = 1 * time.Nanosecond - staleArtist := &model.Artist{ - ID: "artist-expired", - Name: "Expired Artist", - LargeImageUrl: "http://example.com/expired-large.jpg", - ExternalInfoUpdatedAt: new(time.Now().Add(-1 * time.Hour)), - } - mockArtistRepo.On("Get", "artist-expired").Return(staleArtist, nil).Maybe() - expectedURL, _ := url.Parse("http://example.com/expired-large.jpg") - - // Capture log output - var logBuf bytes.Buffer - log.SetOutput(&logBuf) - defer log.SetOutput(GinkgoWriter) - log.SetLevel(log.LevelDebug) - - // Act - imgURL, err := provider.ArtistImage(ctx, "artist-expired") - - // Assert: returns stale URL immediately, no agent call - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, "artist-expired", mock.Anything, mock.Anything) - - // Assert: background refresh was enqueued - Expect(logBuf.String()).To(ContainSubstring("Artist image info expired, enqueuing background refresh")) - }) - - Describe("ArtistImageResult", func() { - It("returns the real agent error on a transient failure, not ErrNotFound", func() { - agentErr := errors.New("agent timed out") - mockImageAgent.Mock = mock.Mock{} - mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").Return(nil, agentErr).Once() - - imgURL, err := provider.ArtistImageResult(ctx, "artist-1") - - Expect(err).To(MatchError(agentErr)) - Expect(err).ToNot(MatchError(model.ErrNotFound)) - Expect(imgURL).To(BeNil()) - }) - - It("returns ErrNotFound when the agent definitively has no image", func() { - mockImageAgent.Mock = mock.Mock{} - mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").Return(nil, agents.ErrNotFound).Once() - - imgURL, err := provider.ArtistImageResult(ctx, "artist-1") - - Expect(err).To(MatchError(model.ErrNotFound)) - Expect(imgURL).To(BeNil()) - }) - - It("returns ErrNotFound when the agent returns no images without error", func() { - mockImageAgent.Mock = mock.Mock{} - mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").Return([]agents.ExternalImage{}, nil).Once() - - imgURL, err := provider.ArtistImageResult(ctx, "artist-1") - - Expect(err).To(MatchError(model.ErrNotFound)) - Expect(imgURL).To(BeNil()) - }) - - It("returns the largest image URL on success", func() { - expectedURL, _ := url.Parse("http://example.com/large.jpg") - - imgURL, err := provider.ArtistImageResult(ctx, "artist-1") - - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - }) - }) - - Context("Unicode handling in artist names", func() { - var artistWithEnDash *model.Artist - var expectedURL *url.URL - - const ( - originalArtistName = "Run–D.M.C." // Artist name with en dash - normalizedArtistName = "Run-D.M.C." // Normalized version with hyphen - ) - - BeforeEach(func() { - // Test with en dash (–) in artist name like "Run–D.M.C." - artistWithEnDash = &model.Artist{ID: "artist-endash", Name: originalArtistName} - mockArtistRepo.Mock = mock.Mock{} // Reset default expectations - mockArtistRepo.On("Get", "artist-endash").Return(artistWithEnDash, nil).Once() - - expectedURL, _ = url.Parse("http://example.com/rundmc.jpg") - - // Mock the image agent to return an image for the artist - mockImageAgent.On("GetArtistImages", ctx, "artist-endash", mock.AnythingOfType("string"), ""). - Return([]agents.ExternalImage{ - {URL: "http://example.com/rundmc.jpg", Size: 1000}, - }, nil).Once() - - }) - - When("DevPreserveUnicodeInExternalCalls is true", func() { - BeforeEach(func() { - conf.Server.DevPreserveUnicodeInExternalCalls = true - }) - It("preserves Unicode characters in artist names", func() { - // Act - imgURL, err := provider.ArtistImage(ctx, "artist-endash") - - // Assert - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-endash") - // This is the key assertion: ensure the original Unicode name is used - mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-endash", originalArtistName, "") - }) - }) - - When("DevPreserveUnicodeInExternalCalls is false", func() { - BeforeEach(func() { - conf.Server.DevPreserveUnicodeInExternalCalls = false - }) - - It("normalizes Unicode characters", func() { - // Act - imgURL, err := provider.ArtistImage(ctx, "artist-endash") - - // Assert - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-endash") - // This assertion ensures the normalized name is used (en dash → hyphen) - mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-endash", normalizedArtistName, "") - }) - }) - }) -}) - -// mockArtistImageAgent implementation using testify/mock -// This remains local as it's specific to testing the ArtistImage functionality -type mockArtistImageAgent struct { - mock.Mock - agents.ArtistImageRetriever // Embed interface -} - -// Constructor for the mock agent -func newMockArtistImageAgent() *mockArtistImageAgent { - mock := new(mockArtistImageAgent) - // Set default AgentName if needed, although usually called via mockAgents - mock.On("AgentName").Return("mockImage").Maybe() - return mock -} - -func (m *mockArtistImageAgent) AgentName() string { - args := m.Called() - return args.String(0) -} - -func (m *mockArtistImageAgent) GetArtistImages(ctx context.Context, id, artistName, mbid string) ([]agents.ExternalImage, error) { - args := m.Called(ctx, id, artistName, mbid) - // Need careful type assertion for potentially nil slice - var res []agents.ExternalImage - if args.Get(0) != nil { - res = args.Get(0).([]agents.ExternalImage) - } - return res, args.Error(1) -} - -// Ensure mockAgent implements the interface -var _ agents.ArtistImageRetriever = (*mockArtistImageAgent)(nil) diff --git a/persistence/e2e/e2e_suite_test.go b/persistence/e2e/e2e_suite_test.go index 1ff2139e6..ac7668218 100644 --- a/persistence/e2e/e2e_suite_test.go +++ b/persistence/e2e/e2e_suite_test.go @@ -14,7 +14,6 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/storage/storagetest" @@ -275,7 +274,7 @@ var _ = BeforeSuite(func() { ctx = request.WithUser(GinkgoT().Context(), adminUser) buildTestFS() - s := scanner.New(ctx, initDS, artwork.NoopCacheWarmer(), events.NoopBroker(), + s := scanner.New(ctx, initDS, events.NoopBroker(), playlists.NewPlaylists(initDS, core.NewImageUploadService()), metrics.NewNoopInstance()) _, err = s.ScanAll(ctx, true) Expect(err).ToNot(HaveOccurred()) diff --git a/scanner/controller.go b/scanner/controller.go index 463718ba3..df5aeb6f9 100644 --- a/scanner/controller.go +++ b/scanner/controller.go @@ -11,7 +11,6 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playlists" @@ -28,12 +27,11 @@ var ( ErrAlreadyScanning = errors.New("already scanning") ) -func New(rootCtx context.Context, ds model.DataStore, cw artwork.CacheWarmer, broker events.Broker, +func New(rootCtx context.Context, ds model.DataStore, broker events.Broker, pls playlists.Playlists, m metrics.Metrics) model.Scanner { c := &controller{ rootCtx: rootCtx, ds: ds, - cw: cw, broker: broker, pls: pls, metrics: m, @@ -49,7 +47,7 @@ func (s *controller) getScanner() scanner { if s.devExternalScanner { return &scannerExternal{} } - return &scannerImpl{ds: s.ds, cw: s.cw, pls: s.pls} + return &scannerImpl{ds: s.ds, pls: s.pls} } // CallScan starts an in-process scan of specific library/folder pairs. @@ -66,7 +64,7 @@ func CallScan(ctx context.Context, ds model.DataStore, pls playlists.Playlists, progress := make(chan *ProgressInfo, 100) go func() { defer close(progress) - scanner := &scannerImpl{ds: ds, cw: artwork.NoopCacheWarmer(), pls: pls} + scanner := &scannerImpl{ds: ds, pls: pls} scanner.scanFolders(ctx, fullScan, targets, progress) }() return progress, nil @@ -97,7 +95,6 @@ type scanner interface { type controller struct { rootCtx context.Context ds model.DataStore - cw artwork.CacheWarmer broker events.Broker metrics metrics.Metrics pls playlists.Playlists diff --git a/scanner/controller_test.go b/scanner/controller_test.go index e4814da64..921841a2d 100644 --- a/scanner/controller_test.go +++ b/scanner/controller_test.go @@ -6,7 +6,6 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/db" @@ -32,7 +31,7 @@ var _ = Describe("Controller", func() { DeferCleanup(configtest.SetupConfig()) ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} ds.MockedProperty = &tests.MockedPropertyRepo{} - ctrl = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) + ctrl = scanner.New(ctx, ds, events.NoopBroker(), playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) }) It("includes last scan error", func() { diff --git a/scanner/phase_1_folders.go b/scanner/phase_1_folders.go index 65f26b9d9..f55f975dc 100644 --- a/scanner/phase_1_folders.go +++ b/scanner/phase_1_folders.go @@ -16,7 +16,6 @@ import ( ppl "github.com/google/go-pipeline/pkg/pipeline" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/storage" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -26,7 +25,7 @@ import ( "github.com/navidrome/navidrome/utils/slice" ) -func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStore, cw artwork.CacheWarmer) *phaseFolders { +func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStore) *phaseFolders { var jobs []*scanJob // Create scan jobs for all libraries @@ -37,7 +36,7 @@ func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStor targetFolders = state.targets[lib.ID] } - job, err := newScanJob(ctx, ds, cw, lib, state.fullScan, targetFolders) + job, err := newScanJob(ctx, ds, lib, state.fullScan, targetFolders) if err != nil { log.Error(ctx, "Scanner: Error creating scan context", "lib", lib.Name, err) state.sendError(err) @@ -52,14 +51,13 @@ func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStor type scanJob struct { lib model.Library fs storage.MusicFS - cw artwork.CacheWarmer lastUpdates map[string]model.FolderUpdateInfo // Holds last update info for all (DB) folders in this library targetFolders []string // Specific folders to scan (including all descendants) lock sync.Mutex numFolders atomic.Int64 } -func newScanJob(ctx context.Context, ds model.DataStore, cw artwork.CacheWarmer, lib model.Library, fullScan bool, targetFolders []string) (*scanJob, error) { +func newScanJob(ctx context.Context, ds model.DataStore, lib model.Library, fullScan bool, targetFolders []string) (*scanJob, error) { // Get folder updates, optionally filtered to specific target folders lastUpdates, err := ds.Folder(ctx).GetFolderUpdateInfo(lib, targetFolders...) if err != nil { @@ -85,7 +83,6 @@ func newScanJob(ctx context.Context, ds model.DataStore, cw artwork.CacheWarmer, return &scanJob{ lib: lib, fs: fsys, - cw: cw, lastUpdates: lastUpdates, targetFolders: targetFolders, }, nil @@ -330,8 +327,6 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error) defer p.measure(entry)() p.state.changesDetected.Store(true) - // Collect artwork IDs to pre-cache after the transaction commits - var artworkIDs []model.ArtworkID // Collect artwork queue items for changed albums/artists, enqueued in the same transaction var queueItems []model.ArtworkQueueItem @@ -373,7 +368,6 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error) return err } if entry.artists[i].Name != consts.UnknownArtist && entry.artists[i].Name != consts.VariousArtists { - artworkIDs = append(artworkIDs, entry.artists[i].CoverArtID()) queueItems = append(queueItems, model.ArtworkQueueItem{ ItemKind: "ar", ItemID: entry.artists[i].ID, ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityScan, @@ -389,7 +383,6 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error) return err } if entry.albums[i].Name != consts.UnknownAlbum { - artworkIDs = append(artworkIDs, entry.albums[i].CoverArtID()) queueItems = append(queueItems, model.ArtworkQueueItem{ ItemKind: "al", ItemID: entry.albums[i].ID, ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityScan, @@ -449,13 +442,6 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error) log.Error(p.ctx, "Scanner: Error persisting changes to DB", "folder", entry.path, err) } - // Pre-cache artwork after the transaction commits successfully - if err == nil { - for _, artID := range artworkIDs { - entry.job.cw.PreCache(artID) - } - } - return entry, err } diff --git a/scanner/phase_4_playlists.go b/scanner/phase_4_playlists.go index 15798b054..04ad2c276 100644 --- a/scanner/phase_4_playlists.go +++ b/scanner/phase_4_playlists.go @@ -12,7 +12,6 @@ import ( ppl "github.com/google/go-pipeline/pkg/pipeline" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -24,18 +23,16 @@ type phasePlaylists struct { scanState *scanState ds model.DataStore pls playlists.Playlists - cw artwork.CacheWarmer refreshed atomic.Uint32 pendingImport bool } -func createPhasePlaylists(ctx context.Context, scanState *scanState, ds model.DataStore, pls playlists.Playlists, cw artwork.CacheWarmer) *phasePlaylists { +func createPhasePlaylists(ctx context.Context, scanState *scanState, ds model.DataStore, pls playlists.Playlists) *phasePlaylists { return &phasePlaylists{ ctx: ctx, scanState: scanState, ds: ds, pls: pls, - cw: cw, } } @@ -148,7 +145,6 @@ func (p *phasePlaylists) processPlaylistsInFolder(folder *model.Folder) (*model. } else { log.Debug("Scanner: Imported playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "numTracks", len(pls.Tracks), "elapsed", time.Since(started)) } - p.cw.PreCache(pls.CoverArtID()) item := model.ArtworkQueueItem{ItemKind: "pl", ItemID: pls.ID, ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityScan} if err := p.ds.ArtworkQueue(p.ctx).Enqueue(item); err != nil { diff --git a/scanner/phase_4_playlists_test.go b/scanner/phase_4_playlists_test.go index 1adf34a63..93ec1a36d 100644 --- a/scanner/phase_4_playlists_test.go +++ b/scanner/phase_4_playlists_test.go @@ -10,7 +10,6 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" @@ -27,7 +26,6 @@ var _ = Describe("phasePlaylists", func() { folderRepo *mockFolderRepository ds *tests.MockDataStore pls *mockPlaylists - cw artwork.CacheWarmer ) var userRepo *tests.MockedUserRepo @@ -48,9 +46,8 @@ var _ = Describe("phasePlaylists", func() { MockedProperty: propRepo, } pls = &mockPlaylists{} - cw = artwork.NoopCacheWarmer() state = &scanState{} - phase = createPhasePlaylists(ctx, state, ds, pls, cw) + phase = createPhasePlaylists(ctx, state, ds, pls) }) Describe("description", func() { diff --git a/scanner/scanner.go b/scanner/scanner.go index 27e2b19d2..8f47c0b7d 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -11,7 +11,6 @@ import ( ppl "github.com/google/go-pipeline/pkg/pipeline" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -21,7 +20,6 @@ import ( type scannerImpl struct { ds model.DataStore - cw artwork.CacheWarmer pls playlists.Playlists } @@ -136,7 +134,7 @@ func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets [] err = run.Sequentially( // Phase 1: Scan all libraries and import new/updated files - runPhase[*folderEntry](ctx, 1, createPhaseFolders(ctx, &state, s.ds, s.cw)), + runPhase[*folderEntry](ctx, 1, createPhaseFolders(ctx, &state, s.ds)), // Phase 2: Process missing files, checking for moves runPhase[*missingTracks](ctx, 2, createPhaseMissingTracks(ctx, &state, s.ds)), @@ -147,7 +145,7 @@ func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets [] runPhase[*model.Album](ctx, 3, createPhaseRefreshAlbums(ctx, &state, s.ds)), // Phase 4: Import/update playlists - runPhase[*model.Folder](ctx, 4, createPhasePlaylists(ctx, &state, s.ds, s.pls, s.cw)), + runPhase[*model.Folder](ctx, 4, createPhasePlaylists(ctx, &state, s.ds, s.pls)), ), // Final Steps (cannot be parallelized): diff --git a/scanner/scanner_benchmark_test.go b/scanner/scanner_benchmark_test.go index 8f0dcd340..6252b272a 100644 --- a/scanner/scanner_benchmark_test.go +++ b/scanner/scanner_benchmark_test.go @@ -13,7 +13,6 @@ import ( "github.com/google/uuid" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/storage/storagetest" @@ -40,7 +39,7 @@ func BenchmarkScan(b *testing.B) { ds := persistence.New(db.Db()) conf.Server.DevExternalScanner = false - s := scanner.New(context.Background(), ds, artwork.NoopCacheWarmer(), events.NoopBroker(), + s := scanner.New(context.Background(), ds, events.NoopBroker(), playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) fs := storagetest.FakeFS{} diff --git a/scanner/scanner_multilibrary_test.go b/scanner/scanner_multilibrary_test.go index 3ae50933c..83e174444 100644 --- a/scanner/scanner_multilibrary_test.go +++ b/scanner/scanner_multilibrary_test.go @@ -12,7 +12,6 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/storage/storagetest" @@ -78,7 +77,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { } Expect(ds.User(ctx).Put(&adminUser)).To(Succeed()) - s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), + s = scanner.New(ctx, ds, events.NoopBroker(), playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) // Create two test libraries (let DB auto-assign IDs) diff --git a/scanner/scanner_selective_test.go b/scanner/scanner_selective_test.go index 17772bf9d..4eec23e89 100644 --- a/scanner/scanner_selective_test.go +++ b/scanner/scanner_selective_test.go @@ -11,7 +11,6 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/storage/storagetest" @@ -66,7 +65,7 @@ var _ = Describe("ScanFolders", Ordered, func() { } Expect(ds.User(ctx).Put(&adminUser)).To(Succeed()) - s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), + s = scanner.New(ctx, ds, events.NoopBroker(), playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) lib = model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"} diff --git a/scanner/scanner_test.go b/scanner/scanner_test.go index b8044c587..46c45e522 100644 --- a/scanner/scanner_test.go +++ b/scanner/scanner_test.go @@ -14,7 +14,6 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/storage/storagetest" @@ -86,7 +85,7 @@ var _ = Describe("Scanner", Ordered, func() { } Expect(ds.User(ctx).Put(&adminUser)).To(Succeed()) - s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), + s = scanner.New(ctx, ds, events.NoopBroker(), playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) lib = model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"} diff --git a/server/subsonic/e2e/e2e_suite_test.go b/server/subsonic/e2e/e2e_suite_test.go index bd4651897..c84680a11 100644 --- a/server/subsonic/e2e/e2e_suite_test.go +++ b/server/subsonic/e2e/e2e_suite_test.go @@ -357,18 +357,6 @@ func (n noopProvider) TopSongs(context.Context, string, int) (model.MediaFiles, return nil, nil } -func (n noopProvider) ArtistImage(context.Context, string) (*url.URL, error) { - return nil, model.ErrNotFound -} - -func (n noopProvider) ArtistImageResult(context.Context, string) (*url.URL, error) { - return nil, model.ErrNotFound -} - -func (n noopProvider) AlbumImage(context.Context, string) (*url.URL, error) { - return nil, model.ErrNotFound -} - // Compile-time interface checks var ( _ artwork.Service = noopArtwork{} @@ -420,7 +408,7 @@ func setupTestDB() { // Create the Subsonic Router with real DS, streamer spy, and real Decider streamerSpy = &harness.SpyStreamer{} decider := stream.NewTranscodeDecider(ds, harness.NoopFFmpeg{}) - s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), + s := scanner.New(ctx, ds, events.NoopBroker(), playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) router = subsonic.New( ds, diff --git a/server/subsonic/e2e/subsonic_multilibrary_test.go b/server/subsonic/e2e/subsonic_multilibrary_test.go index e652cf492..2ecc3d863 100644 --- a/server/subsonic/e2e/subsonic_multilibrary_test.go +++ b/server/subsonic/e2e/subsonic_multilibrary_test.go @@ -7,7 +7,6 @@ import ( "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/storage/storagetest" @@ -53,7 +52,7 @@ var _ = Describe("Multi-Library Support", Ordered, func() { adminWithLibs = *loadedAdmin // Run incremental scan to import lib2 content (lib1 files unchanged → skipped) - s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), + s := scanner.New(ctx, ds, events.NoopBroker(), playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) _, err = s.ScanAll(ctx, false) Expect(err).ToNot(HaveOccurred()) diff --git a/tests/harness/harness.go b/tests/harness/harness.go index ff5ce8919..dabb6f0ea 100644 --- a/tests/harness/harness.go +++ b/tests/harness/harness.go @@ -14,7 +14,6 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/core/metrics" @@ -74,7 +73,7 @@ func SetupDB(ctx context.Context, users ...*model.User) *DB { u.Libraries = loaded.Libraries } - s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), + s := scanner.New(ctx, ds, events.NoopBroker(), playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) _, err := s.ScanAll(ctx, true) Expect(err).ToNot(HaveOccurred()) From 0d1df1648e1084c962b933c78a165d85aed29c75 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 22:45:28 -0400 Subject: [PATCH 65/87] feat(artwork): precache on acquisition, bump on upload/radio changes, manual re-resolve API --- cmd/pls.go | 2 +- cmd/scan.go | 2 +- cmd/wire_gen.go | 13 +-- core/artwork/processor.go | 2 + core/artwork/serving.go | 30 +++--- core/artwork/worker.go | 36 ++++++- core/artwork/worker_test.go | 81 +++++++++++++++- core/artwork/worker_timing_test.go | 2 +- core/image_upload.go | 34 ++++++- core/image_upload_test.go | 30 +++++- core/playlists/import_test.go | 42 ++++---- core/playlists/playlists_test.go | 18 ++-- core/playlists/rest_adapter_test.go | 2 +- persistence/e2e/e2e_suite_test.go | 2 +- persistence/radio_repository.go | 5 +- persistence/radio_repository_test.go | 2 +- scanner/controller_test.go | 2 +- scanner/scanner_benchmark_test.go | 2 +- scanner/scanner_multilibrary_test.go | 2 +- scanner/scanner_selective_test.go | 2 +- scanner/scanner_test.go | 2 +- server/jellyfin/e2e/e2e_suite_test.go | 2 +- server/nativeapi/artwork.go | 49 ++++++++++ server/nativeapi/artwork_test.go | 95 +++++++++++++++++++ server/nativeapi/native_api.go | 1 + server/subsonic/e2e/e2e_suite_test.go | 4 +- .../e2e/subsonic_multilibrary_test.go | 2 +- .../e2e/subsonic_sonic_similarity_test.go | 2 +- tests/harness/harness.go | 2 +- 29 files changed, 392 insertions(+), 78 deletions(-) create mode 100644 server/nativeapi/artwork.go create mode 100644 server/nativeapi/artwork_test.go diff --git a/cmd/pls.go b/cmd/pls.go index 95cbe4eec..0203fe3a9 100644 --- a/cmd/pls.go +++ b/cmd/pls.go @@ -260,7 +260,7 @@ func runImport(ctx context.Context, files []string) { ctx = request.WithUser(ctx, *user) } - pls := playlists.NewPlaylists(ds, core.NewImageUploadService()) + pls := playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) for _, file := range files { absPath, err := filepath.Abs(file) diff --git a/cmd/scan.go b/cmd/scan.go index 320b401d4..c75a7aed4 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -82,7 +82,7 @@ func runScanner(ctx context.Context) { sqlDB := db.Db() defer db.Db().Close() ds := persistence.New(sqlDB) - pls := playlists.NewPlaylists(ds, core.NewImageUploadService()) + pls := playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) // Parse targets from command line or file var scanTargets []model.ScanTarget diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index cbd903e3c..82d0ecc89 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -65,7 +65,7 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router { sqlDB := db.Db() dataStore := persistence.New(sqlDB) share := core.NewShare(dataStore) - imageUploadService := core.NewImageUploadService() + imageUploadService := core.NewImageUploadService(dataStore) playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) insights := metrics.GetInstance(dataStore) broker := events.GetBroker() @@ -98,7 +98,7 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { agentsAgents := agents.GetAgents(dataStore, manager) matcherMatcher := matcher.New(dataStore) provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) - imageUploadService := core.NewImageUploadService() + imageUploadService := core.NewImageUploadService(dataStore) playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics) playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) @@ -125,7 +125,7 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router { metricsMetrics := metrics.GetPrometheusInstance(dataStore) manager := plugins.GetManager(dataStore, broker, metricsMetrics) playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) - imageUploadService := core.NewImageUploadService() + imageUploadService := core.NewImageUploadService(dataStore) playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) agentsAgents := agents.GetAgents(dataStore, manager) matcherMatcher := matcher.New(dataStore) @@ -183,7 +183,7 @@ func CreateScanner(ctx context.Context) model.Scanner { sqlDB := db.Db() dataStore := persistence.New(sqlDB) broker := events.GetBroker() - imageUploadService := core.NewImageUploadService() + imageUploadService := core.NewImageUploadService(dataStore) playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) metricsMetrics := metrics.GetPrometheusInstance(dataStore) modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics) @@ -194,7 +194,7 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher { sqlDB := db.Db() dataStore := persistence.New(sqlDB) broker := events.GetBroker() - imageUploadService := core.NewImageUploadService() + imageUploadService := core.NewImageUploadService(dataStore) playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) metricsMetrics := metrics.GetPrometheusInstance(dataStore) modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics) @@ -218,7 +218,8 @@ func CreateArtworkWorker() *artwork.Worker { manager := plugins.GetManager(dataStore, broker, metricsMetrics) agentsAgents := agents.GetAgents(dataStore, manager) fFmpeg := ffmpeg.New() - worker := artwork.NewWorker(dataStore, imageStore, agentsAgents, fFmpeg, broker) + fileCache := artwork.GetImageCache() + worker := artwork.NewWorker(dataStore, imageStore, agentsAgents, fFmpeg, broker, fileCache) return worker } diff --git a/core/artwork/processor.go b/core/artwork/processor.go index 28231b65e..4272d41b8 100644 --- a/core/artwork/processor.go +++ b/core/artwork/processor.go @@ -15,6 +15,7 @@ import ( "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/cache" xdraw "golang.org/x/image/draw" ) @@ -49,6 +50,7 @@ type workerDeps struct { store *ImageStore agents *agents.Agents ffmpeg ffmpeg.FFmpeg + cache cache.FileCache gate gateFunc } diff --git a/core/artwork/serving.go b/core/artwork/serving.go index a2f9c57ef..38a474355 100644 --- a/core/artwork/serving.go +++ b/core/artwork/serving.go @@ -106,21 +106,14 @@ func (s *service) serveHash(ctx context.Context, artID model.ArtworkID, ia *mode } if size == 0 && !square { - rc, err := s.openOriginal(ia, art.Mime) + rc, err := openOriginal(ia, art.Mime, s.store) if err != nil { return s.dangling(ctx, artID) } return &Image{ReadCloser: rc, Hash: ia.Hash, LastUpdated: ia.UpdatedAt}, nil } - item := &resizedItem{ - hash: ia.Hash, - size: size, - square: square, - lastUpdate: ia.UpdatedAt, - ffmpeg: s.ffmpeg, - open: func() (io.ReadCloser, error) { return s.openOriginal(ia, art.Mime) }, - } + item := newResizedItem(ia, art.Mime, size, square, s.store, s.ffmpeg) stream, err := s.cache.Get(ctx, item) if err != nil { if errors.Is(err, context.Canceled) { @@ -133,7 +126,7 @@ func (s *service) serveHash(ctx context.Context, artID model.ArtworkID, ia *mode // openOriginal opens the full-resolution bytes for a found state row, enforcing the // mtime invariant: bytes are never served under a hash they no longer match. -func (s *service) openOriginal(ia *model.ItemArtwork, mime string) (io.ReadCloser, error) { +func openOriginal(ia *model.ItemArtwork, mime string, store *ImageStore) (io.ReadCloser, error) { if isFileBacked(ia.Source) { f, err := os.Open(ia.SourcePath) if err != nil { @@ -161,7 +154,20 @@ func (s *service) openOriginal(ia *model.ItemArtwork, mime string) (io.ReadClose return nil, errStaleSource } } - return s.store.Open(ia.Hash, mime) + return store.Open(ia.Hash, mime) +} + +// newResizedItem builds the resize-cache reader for a found state row's bytes; shared by +// the serving path and the worker's precache so both key the cache identically. +func newResizedItem(ia *model.ItemArtwork, mime string, size int, square bool, store *ImageStore, ffm ffmpeg.FFmpeg) *resizedItem { + return &resizedItem{ + hash: ia.Hash, + size: size, + square: square, + lastUpdate: ia.UpdatedAt, + ffmpeg: ffm, + open: func() (io.ReadCloser, error) { return openOriginal(ia, mime, store) }, + } } // provisional does a local-only read-through for an entity with no state row: it enqueues @@ -332,7 +338,7 @@ func unixMtime(mtime int64) time.Time { } // resizedItem is an artworkReader that resizes bytes opened by open() and caches the -// result under a hash-derived key, sharing the image cache with the legacy readers. +// result under a hash-derived key. type resizedItem struct { hash string size int diff --git a/core/artwork/worker.go b/core/artwork/worker.go index a71495441..01ff548de 100644 --- a/core/artwork/worker.go +++ b/core/artwork/worker.go @@ -16,6 +16,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/utils/cache" "golang.org/x/time/rate" ) @@ -53,9 +54,9 @@ type Worker struct { inFlight map[string]struct{} } -func NewWorker(ds model.DataStore, store *ImageStore, ag *agents.Agents, ffmpeg ffmpeg.FFmpeg, broker events.Broker) *Worker { +func NewWorker(ds model.DataStore, store *ImageStore, ag *agents.Agents, ffmpeg ffmpeg.FFmpeg, broker events.Broker, imgCache cache.FileCache) *Worker { w := &Worker{ - deps: workerDeps{ds: ds, store: store, agents: ag, ffmpeg: ffmpeg}, + deps: workerDeps{ds: ds, store: store, agents: ag, ffmpeg: ffmpeg, cache: imgCache}, broker: broker, wake: make(chan struct{}, 1), runCtx: context.Background(), @@ -148,6 +149,9 @@ func (w *Worker) drain(ctx context.Context, concurrency int) (int, error) { foundMu.Lock() found = append(found, it) foundMu.Unlock() + // Post-outcome only: the queue row was already settled by process, so warming + // the resize cache here can never block or alter queue-op handling. + w.precache(ctx, it) } }(item) } @@ -215,6 +219,34 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) outco return out } +// precache warms the resize cache for a newly-acquired image at the UI cover size, so the +// first UI request is a cache hit. Skipped when disabled; failures are debug-only. +func (w *Worker) precache(ctx context.Context, item model.ArtworkQueueItem) { + if !conf.Server.EnableArtworkPrecache || w.deps.cache == nil || w.deps.cache.Disabled(ctx) { + return + } + imageType := item.ImageType + if imageType == "" { + imageType = model.ImageTypePrimary + } + repo := w.deps.ds.Artwork(ctx) + ia, err := repo.GetItemArtwork(item.ItemKind, item.ItemID, imageType) + if err != nil || ia.Hash == "" { + return + } + art, err := repo.GetImage(ia.Hash) + if err != nil { + return + } + stream, err := w.deps.cache.Get(ctx, newResizedItem(ia, art.Mime, conf.Server.UICoverArtSize, false, w.deps.store, w.deps.ffmpeg)) + if err != nil { + log.Debug(ctx, "artwork: precache failed", "kind", item.ItemKind, "id", item.ItemID, err) + return + } + _, _ = io.Copy(io.Discard, stream) + _ = stream.Close() +} + // claim reserves items not already in flight, so a row appearing twice within a single // batch is processed once. func (w *Worker) claim(batch []model.ArtworkQueueItem) []model.ArtworkQueueItem { diff --git a/core/artwork/worker_test.go b/core/artwork/worker_test.go index 97bbc41c8..3917036c7 100644 --- a/core/artwork/worker_test.go +++ b/core/artwork/worker_test.go @@ -15,11 +15,38 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/cache" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "go.uber.org/goleak" ) +// recordingCache captures the keys passed to Get so precache warming can be asserted, +// and can be forced Disabled to exercise the skip path. +type recordingCache struct { + cache.FileCache + mu sync.Mutex + keys []string + disabled bool +} + +func (c *recordingCache) Disabled(ctx context.Context) bool { + return c.disabled || c.FileCache.Disabled(ctx) +} + +func (c *recordingCache) Get(ctx context.Context, arg cache.Item) (*cache.CachedStream, error) { + c.mu.Lock() + c.keys = append(c.keys, arg.Key()) + c.mu.Unlock() + return c.FileCache.Get(ctx, arg) +} + +func (c *recordingCache) getKeys() []string { + c.mu.Lock() + defer c.mu.Unlock() + return append([]string(nil), c.keys...) +} + // reenqueueOnDequeue simulates a concurrent scan Enqueue between DequeueBatch and the // worker's delete by bumping retry_at, so a DeleteIfUnchanged on the dequeued value no-ops. type reenqueueOnDequeue struct { @@ -88,6 +115,7 @@ var _ = Describe("Worker", func() { artRepo *tests.MockArtworkRepo queueRepo *tests.MockArtworkQueueRepo broker *fakeEventBroker + imgCache *recordingCache repoRoot string w *Worker ) @@ -98,6 +126,7 @@ var _ = Describe("Worker", func() { var err error repoRoot, err = os.Getwd() Expect(err).ToNot(HaveOccurred()) + conf.Server.CacheFolder = conf.NewDir(GinkgoT().TempDir()) folderRepo = &fakeFolderRepo{} libRepo = &tests.MockLibraryRepo{} @@ -117,7 +146,13 @@ var _ = Describe("Worker", func() { conf.Server.CoverArtPriority = "cover.jpg, embedded" conf.Server.ArtworkExternalMaxRPS = 1000 // keep the limiter out of the way of behavior tests broker = &fakeEventBroker{} - w = NewWorker(ds, store, ag, ffm, broker) + imgCache = &recordingCache{FileCache: cache.NewFileCache("WorkerTest", "100MB", "images", 0, + func(ctx context.Context, arg cache.Item) (io.Reader, error) { + r, _, err := arg.(artworkReader).Reader(ctx) + return r, err + })} + Eventually(func() bool { return imgCache.Available(ctx) }).Should(BeTrue()) + w = NewWorker(ds, store, ag, ffm, broker, imgCache) }) Describe("drain", func() { @@ -238,7 +273,7 @@ var _ = Describe("Worker", func() { }) racing := &reenqueueOnDequeue{MockArtworkQueueRepo: queueRepo} ds.MockedArtworkQueue = racing - w = NewWorker(ds, store, ag, ffm, broker) + w = NewWorker(ds, store, ag, ffm, broker, imgCache) Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ ItemKind: "al", ItemID: "al7", Priority: model.ArtworkPriorityScan, })).To(Succeed()) @@ -260,7 +295,7 @@ var _ = Describe("Worker", func() { imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) racing := &reenqueueOnDequeue{MockArtworkQueueRepo: queueRepo} ds.MockedArtworkQueue = racing - w = NewWorker(ds, store, ag, ffm, broker) + w = NewWorker(ds, store, ag, ffm, broker, imgCache) Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al8"})).To(Succeed()) dequeued := findQueued(queueRepo, "al", "al8").RetryAt @@ -283,7 +318,7 @@ var _ = Describe("Worker", func() { private: model.Playlist{ID: "plPriv", OwnerID: "admin"}, tracks: &tests.MockPlaylistTrackRepo{}, } - w = NewWorker(vds, store, ag, ffm, broker) + w = NewWorker(vds, store, ag, ffm, broker, imgCache) Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plPriv"})).To(Succeed()) n, err := w.drain(ctx, 1) @@ -433,6 +468,42 @@ var _ = Describe("Worker", func() { }) }) + Describe("precache", func() { + BeforeEach(func() { + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }} + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "alpc", Name: "Album", FolderIDs: []string{"f1"}}, + }) + conf.Server.UICoverArtSize = 300 + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ + ItemKind: "al", ItemID: "alpc", Priority: model.ArtworkPriorityScan, + })).To(Succeed()) + }) + + It("warms the resize cache at the UI cover size after a found acquisition", func() { + conf.Server.EnableArtworkPrecache = true + + n, err := w.drain(ctx, 1) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(1)) + + Expect(imgCache.getKeys()).To(ContainElement(ContainSubstring(".300.false."))) + }) + + It("skips warming when precache is disabled", func() { + conf.Server.EnableArtworkPrecache = false + + n, err := w.drain(ctx, 1) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(1)) + + Expect(imgCache.getKeys()).To(BeEmpty()) + }) + }) + Describe("RunPrune", func() { It("runs a prune under the worker mutex", func() { Expect(w.RunPrune(ctx)).To(Succeed()) @@ -456,7 +527,7 @@ var _ = Describe("Worker", func() { DeferCleanup(func() { goleak.VerifyNone(GinkgoT(), ignore) }) localDS := &tests.MockDataStore{MockedArtworkQueue: tests.CreateMockArtworkQueueRepo()} - lw := NewWorker(localDS, NewImageStore(GinkgoT().TempDir()), agents.GetAgents(localDS, nil), tests.NewMockFFmpeg(""), &fakeEventBroker{}) + lw := NewWorker(localDS, NewImageStore(GinkgoT().TempDir()), agents.GetAgents(localDS, nil), tests.NewMockFFmpeg(""), &fakeEventBroker{}, imgCache) runCtx, cancel := context.WithCancel(ctx) done := make(chan error, 1) diff --git a/core/artwork/worker_timing_test.go b/core/artwork/worker_timing_test.go index 159a53081..dbaed0a84 100644 --- a/core/artwork/worker_timing_test.go +++ b/core/artwork/worker_timing_test.go @@ -47,7 +47,7 @@ func TestArtworkGatePerAgentBreakerIsolation(t *testing.T) { synctest.Test(t, func(t *testing.T) { g := NewWithT(t) w := NewWorker(&tests.MockDataStore{}, NewImageStore(t.TempDir()), - agents.GetAgents(&tests.MockDataStore{}, nil), tests.NewMockFFmpeg(""), &fakeEventBroker{}) + agents.GetAgents(&tests.MockDataStore{}, nil), tests.NewMockFFmpeg(""), &fakeEventBroker{}, nil) fail := func() (io.ReadCloser, string, error) { return nil, "", errors.New("boom") } for range breakerThreshold { diff --git a/core/image_upload.go b/core/image_upload.go index eb61b225a..6f5ef418a 100644 --- a/core/image_upload.go +++ b/core/image_upload.go @@ -30,10 +30,20 @@ func MaxImageUploadSize() int64 { return int64(size) } -type imageUploadService struct{} +// uploadEntityKind maps an upload's entity type to its artwork kind prefix, so a +// successful upload can clear and re-queue that item's artwork state. +var uploadEntityKind = map[string]string{ + consts.EntityArtist: model.KindArtistArtwork.Prefix(), + consts.EntityPlaylist: model.KindPlaylistArtwork.Prefix(), + consts.EntityRadio: model.KindRadioArtwork.Prefix(), +} -func NewImageUploadService() ImageUploadService { - return &imageUploadService{} +type imageUploadService struct { + ds model.DataStore +} + +func NewImageUploadService(ds model.DataStore) ImageUploadService { + return &imageUploadService{ds: ds} } func (s *imageUploadService) SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (string, error) { @@ -62,9 +72,27 @@ func (s *imageUploadService) SetImage(ctx context.Context, entityType string, en return "", fmt.Errorf("writing image file: %w", err) } + s.bumpArtwork(ctx, entityType, entityID) return filename, nil } +// bumpArtwork clears the item's resolved state and re-queues it at Bump priority: the +// upload is now the top-priority source, so the worker re-resolves and the UI swaps. +func (s *imageUploadService) bumpArtwork(ctx context.Context, entityType, id string) { + kind, ok := uploadEntityKind[entityType] + if !ok { + return + } + if err := s.ds.Artwork(ctx).DeleteForItem(kind, id); err != nil { + log.Warn(ctx, "Could not clear artwork state after upload", "kind", kind, "id", id, err) + } + item := model.ArtworkQueueItem{ItemKind: kind, ItemID: id, ImageType: model.ImageTypePrimary, + Priority: model.ArtworkPriorityBump} + if err := s.ds.ArtworkQueue(ctx).Enqueue(item); err != nil { + log.Warn(ctx, "Could not enqueue artwork after upload", "kind", kind, "id", id, err) + } +} + func (s *imageUploadService) RemoveImage(ctx context.Context, path string) error { if path == "" { return nil diff --git a/core/image_upload_test.go b/core/image_upload_test.go index e7648df34..1b63b3052 100644 --- a/core/image_upload_test.go +++ b/core/image_upload_test.go @@ -10,6 +10,8 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -17,12 +19,17 @@ import ( var _ = Describe("ImageUploadService", func() { var svc core.ImageUploadService var tmpDir string + var artRepo *tests.MockArtworkRepo + var queueRepo *tests.MockArtworkQueueRepo BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) tmpDir = GinkgoT().TempDir() conf.Server.DataFolder = conf.NewDir(tmpDir) - svc = core.NewImageUploadService() + artRepo = tests.CreateMockArtworkRepo() + queueRepo = tests.CreateMockArtworkQueueRepo() + ds := &tests.MockDataStore{MockedArtwork: artRepo, MockedArtworkQueue: queueRepo} + svc = core.NewImageUploadService(ds) }) Describe("SetImage", func() { @@ -69,6 +76,27 @@ var _ = Describe("ImageUploadService", func() { _, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Name", "/nonexistent/path.jpg", reader, ".jpg") Expect(err).ToNot(HaveOccurred()) }) + + It("clears artwork state and enqueues a Bump on success", func() { + ctx := context.Background() + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + ItemKind: "ar", ItemID: "ar-1", Hash: "oldhash", Source: "external", + })).To(Succeed()) + + _, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Pink Floyd", "", strings.NewReader("img"), ".jpg") + Expect(err).ToNot(HaveOccurred()) + + _, err = artRepo.GetItemArtwork("ar", "ar-1", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + + queued, err := queueRepo.DequeueBatch(1000) + Expect(err).ToNot(HaveOccurred()) + Expect(queued).To(ContainElement(SatisfyAll( + HaveField("ItemKind", "ar"), + HaveField("ItemID", "ar-1"), + HaveField("Priority", model.ArtworkPriorityBump), + ))) + }) }) Describe("RemoveImage", func() { diff --git a/core/playlists/import_test.go b/core/playlists/import_test.go index f2866fb60..730af20cd 100644 --- a/core/playlists/import_test.go +++ b/core/playlists/import_test.go @@ -43,7 +43,7 @@ var _ = Describe("Playlists - Import", func() { var folder *model.Folder BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) ds.MockedMediaFile = &mockedMediaFileRepo{} libPath, _ := os.Getwd() // Set up library with the actual library path that matches the folder @@ -118,7 +118,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3", "test.ogg"}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") @@ -136,7 +136,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") @@ -155,7 +155,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") @@ -174,7 +174,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") @@ -192,7 +192,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") @@ -209,7 +209,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") @@ -226,7 +226,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") @@ -244,7 +244,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") @@ -258,7 +258,7 @@ var _ = Describe("Playlists - Import", func() { tmpDir := GinkgoT().TempDir() mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) m3u := "#EXTALBUMARTURL:https://example.com/new-cover.jpg\ntest.mp3\n" plsFile := filepath.Join(tmpDir, "test.m3u") @@ -285,7 +285,7 @@ var _ = Describe("Playlists - Import", func() { tmpDir := GinkgoT().TempDir() mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) plsFile := filepath.Join(tmpDir, "test.m3u") Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed()) @@ -311,7 +311,7 @@ var _ = Describe("Playlists - Import", func() { tmpDir := GinkgoT().TempDir() mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) m3u := "test.mp3\n" plsFile := filepath.Join(tmpDir, "test.m3u") @@ -388,7 +388,7 @@ var _ = Describe("Playlists - Import", func() { tmpDir := GinkgoT().TempDir() mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) // Create the playlist file on disk with the filesystem's normalization form plsFile := tmpDir + "/" + filesystemName + ".m3u" @@ -448,7 +448,7 @@ var _ = Describe("Playlists - Import", func() { "def.mp3", // This is playlists/def.mp3 relative to plsDir }, } - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) }) It("handles relative paths that reference files in other libraries", func() { @@ -604,7 +604,7 @@ var _ = Describe("Playlists - Import", func() { }, } // Recreate playlists service to pick up new mock - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) // Create playlist in music library that references both tracks plsContent := "#PLAYLIST:Same Path Test\nalbum/track.mp3\n../classical/album/track.mp3" @@ -662,7 +662,7 @@ var _ = Describe("Playlists - Import", func() { }, } ds.MockedFolder = mockFolderRepo - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) plsContent := "#PLAYLIST:My Playlist\ntest.mp3\ntest.ogg\n" plsFile := filepath.Join(tmpDir, "my-playlist.m3u") @@ -681,7 +681,7 @@ var _ = Describe("Playlists - Import", func() { libDir := filepath.Join(tmpDir, "music") Expect(os.Mkdir(libDir, 0755)).To(Succeed()) mockLibRepo.SetData([]model.Library{{ID: 1, Path: libDir}}) - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) plsContent := "#PLAYLIST:External Playlist\n" + libDir + "/test.mp3\n" plsFile := filepath.Join(tmpDir, "external.m3u") @@ -704,7 +704,7 @@ var _ = Describe("Playlists - Import", func() { }, } ds.MockedFolder = mockFolderRepo - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) plsFile := filepath.Join(tmpDir, "test.m3u") Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed()) @@ -724,7 +724,7 @@ var _ = Describe("Playlists - Import", func() { }, } ds.MockedFolder = mockFolderRepo - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) plsFile := filepath.Join(tmpDir, "test.m3u") Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed()) @@ -744,7 +744,7 @@ var _ = Describe("Playlists - Import", func() { }, } ds.MockedFolder = mockFolderRepo - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) plsFile := filepath.Join(tmpDir, "test.m3u") Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed()) @@ -767,7 +767,7 @@ var _ = Describe("Playlists - Import", func() { BeforeEach(func() { repo = &mockedMediaFileFromListRepo{} ds.MockedMediaFile = repo - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/music"}, {ID: 2, Path: "/new"}}) ctx = request.WithUser(ctx, model.User{ID: "123"}) }) diff --git a/core/playlists/playlists_test.go b/core/playlists/playlists_test.go index 0c9674bed..4120fd2f5 100644 --- a/core/playlists/playlists_test.go +++ b/core/playlists/playlists_test.go @@ -42,7 +42,7 @@ var _ = Describe("Playlists", func() { "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, } mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) }) It("allows owner to delete their playlist", func() { @@ -82,7 +82,7 @@ var _ = Describe("Playlists", func() { "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, } mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) }) It("returns the playlist's track repository", func() { @@ -103,7 +103,7 @@ var _ = Describe("Playlists", func() { "pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1", Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, } - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) }) It("creates a new playlist with owner set from context", func() { @@ -161,7 +161,7 @@ var _ = Describe("Playlists", func() { Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, } mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) }) It("allows owner to update their playlist", func() { @@ -219,7 +219,7 @@ var _ = Describe("Playlists", func() { "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"}, } mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) }) It("allows owner to add tracks", func() { @@ -267,7 +267,7 @@ var _ = Describe("Playlists", func() { Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, } mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) }) It("allows owner to remove tracks", func() { @@ -301,7 +301,7 @@ var _ = Describe("Playlists", func() { Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, } mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) }) It("allows owner to reorder", func() { @@ -330,7 +330,7 @@ var _ = Describe("Playlists", func() { "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"}, } - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) }) It("saves image file and updates UploadedImage", func() { @@ -400,7 +400,7 @@ var _ = Describe("Playlists", func() { "pls-empty": {ID: "pls-empty", Name: "No Cover", OwnerID: "user-1"}, "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"}, } - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) }) It("removes file and clears UploadedImage", func() { diff --git a/core/playlists/rest_adapter_test.go b/core/playlists/rest_adapter_test.go index 58a327bde..0c18c416b 100644 --- a/core/playlists/rest_adapter_test.go +++ b/core/playlists/rest_adapter_test.go @@ -37,7 +37,7 @@ var _ = Describe("REST Adapter", func() { mockPlsRepo.Data = map[string]*model.Playlist{ "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, } - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService(ds)) }) Describe("Save", func() { diff --git a/persistence/e2e/e2e_suite_test.go b/persistence/e2e/e2e_suite_test.go index ac7668218..29291f66e 100644 --- a/persistence/e2e/e2e_suite_test.go +++ b/persistence/e2e/e2e_suite_test.go @@ -275,7 +275,7 @@ var _ = BeforeSuite(func() { buildTestFS() s := scanner.New(ctx, initDS, events.NoopBroker(), - playlists.NewPlaylists(initDS, core.NewImageUploadService()), metrics.NewNoopInstance()) + playlists.NewPlaylists(initDS, core.NewImageUploadService(initDS)), metrics.NewNoopInstance()) _, err = s.ScanAll(ctx, true) Expect(err).ToNot(HaveOccurred()) diff --git a/persistence/radio_repository.go b/persistence/radio_repository.go index d48178977..ac3950005 100644 --- a/persistence/radio_repository.go +++ b/persistence/radio_repository.go @@ -106,9 +106,10 @@ func (r *radioRepository) Put(radio *model.Radio, colsToUpdate ...string) error if err != nil { return err } - // Enqueue artwork resolution for the created/updated radio. Never fails the save. + // Enqueue artwork resolution for the created/updated radio at Bump priority so a new + // radio's cover resolves proactively. Never fails the save. item := model.ArtworkQueueItem{ItemKind: "ra", ItemID: radio.ID, ImageType: model.ImageTypePrimary, - Priority: model.ArtworkPriorityScan} + Priority: model.ArtworkPriorityBump} if err := NewArtworkQueueRepository(r.ctx, r.db).Enqueue(item); err != nil { log.Warn(r.ctx, "could not enqueue radio artwork", "id", radio.ID, err) } diff --git a/persistence/radio_repository_test.go b/persistence/radio_repository_test.go index 4b243c142..1ea8b971c 100644 --- a/persistence/radio_repository_test.go +++ b/persistence/radio_repository_test.go @@ -140,7 +140,7 @@ var _ = Describe("RadioRepository", func() { Expect(queued).To(ContainElement(SatisfyAll( HaveField("ItemKind", "ra"), HaveField("ItemID", created.ID), - HaveField("Priority", model.ArtworkPriorityScan), + HaveField("Priority", model.ArtworkPriorityBump), ))) }) }) diff --git a/scanner/controller_test.go b/scanner/controller_test.go index 921841a2d..3dbb36eeb 100644 --- a/scanner/controller_test.go +++ b/scanner/controller_test.go @@ -31,7 +31,7 @@ var _ = Describe("Controller", func() { DeferCleanup(configtest.SetupConfig()) ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} ds.MockedProperty = &tests.MockedPropertyRepo{} - ctrl = scanner.New(ctx, ds, events.NoopBroker(), playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) + ctrl = scanner.New(ctx, ds, events.NoopBroker(), playlists.NewPlaylists(ds, core.NewImageUploadService(ds)), metrics.NewNoopInstance()) }) It("includes last scan error", func() { diff --git a/scanner/scanner_benchmark_test.go b/scanner/scanner_benchmark_test.go index 6252b272a..0abac5f40 100644 --- a/scanner/scanner_benchmark_test.go +++ b/scanner/scanner_benchmark_test.go @@ -40,7 +40,7 @@ func BenchmarkScan(b *testing.B) { ds := persistence.New(db.Db()) conf.Server.DevExternalScanner = false s := scanner.New(context.Background(), ds, events.NoopBroker(), - playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) + playlists.NewPlaylists(ds, core.NewImageUploadService(ds)), metrics.NewNoopInstance()) fs := storagetest.FakeFS{} storagetest.Register("fake", &fs) diff --git a/scanner/scanner_multilibrary_test.go b/scanner/scanner_multilibrary_test.go index 83e174444..e6650f39d 100644 --- a/scanner/scanner_multilibrary_test.go +++ b/scanner/scanner_multilibrary_test.go @@ -78,7 +78,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(ds.User(ctx).Put(&adminUser)).To(Succeed()) s = scanner.New(ctx, ds, events.NoopBroker(), - playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) + playlists.NewPlaylists(ds, core.NewImageUploadService(ds)), metrics.NewNoopInstance()) // Create two test libraries (let DB auto-assign IDs) lib1 = model.Library{Name: "Rock Collection", Path: "rock:///music"} diff --git a/scanner/scanner_selective_test.go b/scanner/scanner_selective_test.go index 4eec23e89..ca87a5f6b 100644 --- a/scanner/scanner_selective_test.go +++ b/scanner/scanner_selective_test.go @@ -66,7 +66,7 @@ var _ = Describe("ScanFolders", Ordered, func() { Expect(ds.User(ctx).Put(&adminUser)).To(Succeed()) s = scanner.New(ctx, ds, events.NoopBroker(), - playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) + playlists.NewPlaylists(ds, core.NewImageUploadService(ds)), metrics.NewNoopInstance()) lib = model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"} Expect(ds.Library(ctx).Put(&lib)).To(Succeed()) diff --git a/scanner/scanner_test.go b/scanner/scanner_test.go index 46c45e522..48eb2fedd 100644 --- a/scanner/scanner_test.go +++ b/scanner/scanner_test.go @@ -86,7 +86,7 @@ var _ = Describe("Scanner", Ordered, func() { Expect(ds.User(ctx).Put(&adminUser)).To(Succeed()) s = scanner.New(ctx, ds, events.NoopBroker(), - playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) + playlists.NewPlaylists(ds, core.NewImageUploadService(ds)), metrics.NewNoopInstance()) lib = model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"} Expect(ds.Library(ctx).Put(&lib)).To(Succeed()) diff --git a/server/jellyfin/e2e/e2e_suite_test.go b/server/jellyfin/e2e/e2e_suite_test.go index 1986a95ec..e7ea172e3 100644 --- a/server/jellyfin/e2e/e2e_suite_test.go +++ b/server/jellyfin/e2e/e2e_suite_test.go @@ -326,7 +326,7 @@ func setupTestDB() { decider, core.NewPlayers(ds), scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), - playlists.NewPlaylists(ds, core.NewImageUploadService()), + playlists.NewPlaylists(ds, core.NewImageUploadService(ds)), providerFake, sonicSvc, lyrics.NewLyrics(ds, nil), diff --git a/server/nativeapi/artwork.go b/server/nativeapi/artwork.go new file mode 100644 index 000000000..cefbccff4 --- /dev/null +++ b/server/nativeapi/artwork.go @@ -0,0 +1,49 @@ +package nativeapi + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +// refreshableArtworkKinds are the entity kinds a manual re-resolve accepts. +var refreshableArtworkKinds = map[string]bool{ + model.KindAlbumArtwork.Prefix(): true, + model.KindArtistArtwork.Prefix(): true, + model.KindPlaylistArtwork.Prefix(): true, + model.KindRadioArtwork.Prefix(): true, + model.KindMediaFileArtwork.Prefix(): true, +} + +func (api *Router) addArtworkRoute(r chi.Router) { + r.Post("/artwork/{kind}/{id}/refresh", api.refreshArtwork()) +} + +// refreshArtwork clears an item's resolved artwork state and re-queues it at Bump priority. +// State is deliberately cleared so a wrong pick disappears immediately (placeholder until re-resolved). +func (api *Router) refreshArtwork() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + kind := chi.URLParam(r, "kind") + id := chi.URLParam(r, "id") + if !refreshableArtworkKinds[kind] { + http.Error(w, "invalid artwork kind", http.StatusBadRequest) + return + } + if err := api.ds.Artwork(ctx).DeleteForItem(kind, id); err != nil { + log.Error(ctx, "Error clearing artwork state", "kind", kind, "id", id, err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + item := model.ArtworkQueueItem{ItemKind: kind, ItemID: id, ImageType: model.ImageTypePrimary, + Priority: model.ArtworkPriorityBump} + if err := api.ds.ArtworkQueue(ctx).Enqueue(item); err != nil { + log.Error(ctx, "Error enqueuing artwork refresh", "kind", kind, "id", id, err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) + } +} diff --git a/server/nativeapi/artwork_test.go b/server/nativeapi/artwork_test.go new file mode 100644 index 000000000..c84e83fdc --- /dev/null +++ b/server/nativeapi/artwork_test.go @@ -0,0 +1,95 @@ +package nativeapi + +import ( + "context" + "net/http" + "net/http/httptest" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Artwork API", func() { + var ds *tests.MockDataStore + var artRepo *tests.MockArtworkRepo + var queueRepo *tests.MockArtworkQueueRepo + var router http.Handler + var adminToken, userToken string + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableSharing = false + artRepo = tests.CreateMockArtworkRepo() + queueRepo = tests.CreateMockArtworkQueueRepo() + ds = &tests.MockDataStore{MockedArtwork: artRepo, MockedArtworkQueue: queueRepo} + auth.Init(ds) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil) + router = server.JWTVerifier(nativeRouter) + + adminUser := model.User{ID: "admin-1", UserName: "admin", IsAdmin: true, NewPassword: "adminpass"} + regularUser := model.User{ID: "user-1", UserName: "regular", IsAdmin: false, NewPassword: "userpass"} + Expect(ds.User(context.TODO()).Put(&adminUser)).To(Succeed()) + Expect(ds.User(context.TODO()).Put(®ularUser)).To(Succeed()) + + var err error + adminToken, err = auth.CreateToken(&adminUser) + Expect(err).ToNot(HaveOccurred()) + userToken, err = auth.CreateToken(®ularUser) + Expect(err).ToNot(HaveOccurred()) + }) + + Describe("POST /api/artwork/{kind}/{id}/refresh", func() { + It("clears state and enqueues a Bump for admins", func() { + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + ItemKind: "al", ItemID: "al-1", Hash: "oldhash", Source: "external", + })).To(Succeed()) + + req := createAuthenticatedRequest("POST", "/artwork/al/al-1/refresh", nil, adminToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + + _, err := artRepo.GetItemArtwork("al", "al-1", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + + queued, err := queueRepo.DequeueBatch(1000) + Expect(err).ToNot(HaveOccurred()) + Expect(queued).To(ContainElement(SatisfyAll( + HaveField("ItemKind", "al"), + HaveField("ItemID", "al-1"), + HaveField("Priority", model.ArtworkPriorityBump), + ))) + }) + + It("returns 400 for an invalid kind", func() { + req := createAuthenticatedRequest("POST", "/artwork/xx/id-1/refresh", nil, adminToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) + + It("denies access to regular users", func() { + req := createAuthenticatedRequest("POST", "/artwork/al/al-1/refresh", nil, userToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusForbidden)) + }) + + It("denies access without authentication", func() { + req := createUnauthenticatedRequest("POST", "/artwork/al/al-1/refresh", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + }) +}) diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 5a7023eb6..3f38e2fe5 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -91,6 +91,7 @@ func (api *Router) routes() http.Handler { api.addConfigRoute(r) api.addUserLibraryRoute(r) api.addPluginRoute(r) + api.addArtworkRoute(r) api.RX(r, "/library", api.libs.NewRepository, true) }) }) diff --git a/server/subsonic/e2e/e2e_suite_test.go b/server/subsonic/e2e/e2e_suite_test.go index c84680a11..af15f17df 100644 --- a/server/subsonic/e2e/e2e_suite_test.go +++ b/server/subsonic/e2e/e2e_suite_test.go @@ -409,7 +409,7 @@ func setupTestDB() { streamerSpy = &harness.SpyStreamer{} decider := stream.NewTranscodeDecider(ds, harness.NoopFFmpeg{}) s := scanner.New(ctx, ds, events.NoopBroker(), - playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) + playlists.NewPlaylists(ds, core.NewImageUploadService(ds)), metrics.NewNoopInstance()) router = subsonic.New( ds, noopArtwork{}, @@ -419,7 +419,7 @@ func setupTestDB() { noopProvider{}, s, events.NoopBroker(), - playlists.NewPlaylists(ds, core.NewImageUploadService()), + playlists.NewPlaylists(ds, core.NewImageUploadService(ds)), scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), core.NewShare(ds), playback.PlaybackServer(nil), diff --git a/server/subsonic/e2e/subsonic_multilibrary_test.go b/server/subsonic/e2e/subsonic_multilibrary_test.go index 2ecc3d863..18ce76b4a 100644 --- a/server/subsonic/e2e/subsonic_multilibrary_test.go +++ b/server/subsonic/e2e/subsonic_multilibrary_test.go @@ -53,7 +53,7 @@ var _ = Describe("Multi-Library Support", Ordered, func() { // Run incremental scan to import lib2 content (lib1 files unchanged → skipped) s := scanner.New(ctx, ds, events.NoopBroker(), - playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) + playlists.NewPlaylists(ds, core.NewImageUploadService(ds)), metrics.NewNoopInstance()) _, err = s.ScanAll(ctx, false) Expect(err).ToNot(HaveOccurred()) diff --git a/server/subsonic/e2e/subsonic_sonic_similarity_test.go b/server/subsonic/e2e/subsonic_sonic_similarity_test.go index 775fefe89..672cdf1d3 100644 --- a/server/subsonic/e2e/subsonic_sonic_similarity_test.go +++ b/server/subsonic/e2e/subsonic_sonic_similarity_test.go @@ -43,7 +43,7 @@ func buildSonicRouter(provider sonic.Provider) *subsonic.Router { noopProvider{}, nil, // scanner events.NoopBroker(), - playlists.NewPlaylists(ds, core.NewImageUploadService()), + playlists.NewPlaylists(ds, core.NewImageUploadService(ds)), scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), core.NewShare(ds), playback.PlaybackServer(nil), diff --git a/tests/harness/harness.go b/tests/harness/harness.go index dabb6f0ea..9e9e9c4ed 100644 --- a/tests/harness/harness.go +++ b/tests/harness/harness.go @@ -74,7 +74,7 @@ func SetupDB(ctx context.Context, users ...*model.User) *DB { } s := scanner.New(ctx, ds, events.NoopBroker(), - playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) + playlists.NewPlaylists(ds, core.NewImageUploadService(ds)), metrics.NewNoopInstance()) _, err := s.ScanAll(ctx, true) Expect(err).ToNot(HaveOccurred()) From dfd4bec27008081947da40937eb9cf8d6326efd0 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 23:31:28 -0400 Subject: [PATCH 66/87] test(artwork): end-to-end coverage for the serving cutover --- core/artwork/e2e/acquire_serve_test.go | 226 ++++++++++++++ core/artwork/e2e/e2e_suite_test.go | 93 ++++++ server/subsonic/e2e/subsonic_artwork_test.go | 308 +++++++++++++++++++ 3 files changed, 627 insertions(+) create mode 100644 core/artwork/e2e/acquire_serve_test.go create mode 100644 core/artwork/e2e/e2e_suite_test.go create mode 100644 server/subsonic/e2e/subsonic_artwork_test.go diff --git a/core/artwork/e2e/acquire_serve_test.go b/core/artwork/e2e/acquire_serve_test.go new file mode 100644 index 000000000..5059e5fe8 --- /dev/null +++ b/core/artwork/e2e/acquire_serve_test.go @@ -0,0 +1,226 @@ +package e2e + +import ( + "context" + "errors" + "io" + "os" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/cache" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// These specs wire the real Worker and Service over the same ImageStore and mock repositories, +// then drive the full enqueue → drain → serve loop. They assert the integration of the chain, not +// the per-source resolution rules (which the unit suites in package artwork already cover). +var _ = Describe("Acquisition → serve loop", func() { + var ( + ctx context.Context + ds *tests.MockDataStore + artRepo *tests.MockArtworkRepo + queueRepo *tests.MockArtworkQueueRepo + albumRepo *tests.MockAlbumRepo + artistRepo *tests.MockArtistRepo + mfRepo *tests.MockMediaFileRepo + plRepo *tests.MockPlaylistRepo + radioRepo *tests.MockedRadioRepo + folderRepo *fakeFolderRepo + libRepo *tests.MockLibraryRepo + store *artwork.ImageStore + svc artwork.Service + worker *artwork.Worker + coverBytes []byte + ) + + // itemFound reports whether the worker has persisted a resolved (hash-bearing) state row. + itemFound := func(kind, id string) func() bool { + return func() bool { + ia, err := artRepo.GetItemArtwork(kind, id, model.ImageTypePrimary) + return err == nil && ia.Hash != "" + } + } + itemAbsent := func(kind, id string) func() bool { + return func() bool { + ia, err := artRepo.GetItemArtwork(kind, id, model.ImageTypePrimary) + return err == nil && ia.Hash == "" + } + } + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + ctx = context.Background() + repoRoot, err := os.Getwd() + Expect(err).ToNot(HaveOccurred()) + coverBytes = readFixture(coverFixture) + + conf.Server.CacheFolder = conf.NewDir(GinkgoT().TempDir()) + conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir()) + conf.Server.CoverArtPriority = "cover.jpg" + conf.Server.ArtistArtPriority = "artist.png" // upload wins first; kept offline as a safety net + conf.Server.EnableMediaFileCoverArt = true + conf.Server.DevArtworkWorkerConcurrency = 1 + + folderRepo = &fakeFolderRepo{} + libRepo = &tests.MockLibraryRepo{} + libRepo.SetData(model.Libraries{{ID: 0, Path: repoRoot}}) + artRepo = tests.CreateMockArtworkRepo() + queueRepo = tests.CreateMockArtworkQueueRepo() + albumRepo = tests.CreateMockAlbumRepo() + artistRepo = tests.CreateMockArtistRepo() + mfRepo = tests.CreateMockMediaFileRepo() + plRepo = tests.CreateMockPlaylistRepo() + radioRepo = tests.CreateMockedRadioRepo() + radioRepo.Data = map[string]*model.Radio{} + ds = &tests.MockDataStore{ + MockedArtwork: artRepo, + MockedArtworkQueue: queueRepo, + MockedAlbum: albumRepo, + MockedArtist: artistRepo, + MockedMediaFile: mfRepo, + MockedPlaylist: plRepo, + MockedRadio: radioRepo, + MockedFolder: folderRepo, + MockedLibrary: libRepo, + } + ffm := tests.NewMockFFmpeg("") + store = artwork.NewImageStore(GinkgoT().TempDir()) + // size=0 requests stream originals and never touch the resize cache, so this reader is a + // compile-time stand-in only; the resize path is covered by the package's serving_test. + imgCache := cache.NewFileCache("ArtworkPipelineE2E", "100MB", "images", 0, + func(context.Context, cache.Item) (io.Reader, error) { + return nil, errors.New("resize not exercised in e2e") + }) + Eventually(func() bool { return imgCache.Available(ctx) }).Should(BeTrue()) + + svc = artwork.NewService(ds, imgCache, store, ffm) + worker = artwork.NewWorker(ds, store, agents.GetAgents(ds, nil), ffm, events.NoopBroker(), imgCache) + }) + + // seedFolderAlbum wires an album backed by the real fixture folder cover, shared by the album + // and playlist-grid scenarios. + seedFolderAlbum := func(albumID string) { + folderRepo.result = []model.Folder{{Path: albumFolderPath, ImageFiles: []string{"cover.jpg"}}} + albumRepo.SetData(model.Albums{{ID: albumID, Name: "Album", FolderIDs: []string{"f1"}, LibraryID: 0}}) + } + + It("acquires album folder art and serves the exact bytes under its hash", func() { + seedFolderAlbum("al1") + worker.Bump("al", "al1") + runWorkerUntil(ctx, worker, itemFound("al", "al1")) + + ia, err := artRepo.GetItemArtwork("al", "al1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("folder")) + + img, err := svc.Get(ctx, model.MustParseArtworkID("al-al1"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(img.Hash).To(Equal(ia.Hash)) + Expect(img.Placeholder).To(BeFalse()) + Expect(readAll(img)).To(Equal(coverBytes)) + }) + + It("acquires an artist's uploaded image and serves it", func() { + name := writeUpload(consts.EntityArtist, "artist-e2e.png", artistPngFixture) + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist", UploadedImage: name}}) + worker.Bump("ar", "ar1") + runWorkerUntil(ctx, worker, itemFound("ar", "ar1")) + + ia, err := artRepo.GetItemArtwork("ar", "ar1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("upload")) + + img, err := svc.Get(ctx, model.MustParseArtworkID("ar-ar1"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(img.Hash).To(Equal(ia.Hash)) + Expect(readAll(img)).To(Equal(readFixture(artistPngFixture))) + }) + + It("generates a playlist grid from its tracks' album art and serves it from the store", func() { + seedFolderAlbum("al1") + plRepo.SetData(model.Playlists{{ID: "pl1", Name: "Playlist"}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"al1"}} + worker.Bump("pl", "pl1") + runWorkerUntil(ctx, worker, itemFound("pl", "pl1")) + + ia, err := artRepo.GetItemArtwork("pl", "pl1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("generated")) + + img, err := svc.Get(ctx, model.MustParseArtworkID("pl-pl1"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(img.Hash).To(Equal(ia.Hash)) + // The generated grid is a fresh PNG placed in the content-addressed store. + art, err := artRepo.GetImage(ia.Hash) + Expect(err).ToNot(HaveOccurred()) + Expect(art.Mime).To(Equal("image/png")) + Expect(len(readAll(img))).To(BeNumerically(">", 0)) + }) + + It("acquires a radio station's uploaded image and serves it", func() { + name := writeUpload(consts.EntityRadio, "radio-e2e.jpg", coverFixture) + radioRepo.Data["ra1"] = &model.Radio{ID: "ra1", Name: "Station", UploadedImage: name} + worker.Bump("ra", "ra1") + runWorkerUntil(ctx, worker, itemFound("ra", "ra1")) + + ia, err := artRepo.GetItemArtwork("ra", "ra1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("upload")) + + img, err := svc.Get(ctx, model.MustParseArtworkID("ra-ra1"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(img.Hash).To(Equal(ia.Hash)) + Expect(readAll(img)).To(Equal(coverBytes)) + }) + + It("serves an unresolved track provisionally, then upgrades to the worker's state row", func() { + mfRepo.SetData(model.MediaFiles{{ + ID: "mf1", AlbumID: "al1", HasCoverArt: true, LibraryID: 0, Path: mp3Fixture, + }}) + + // First read: no state row yet → extract embedded art provisionally and enqueue the track. + provisional, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf1"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(provisional.Placeholder).To(BeFalse()) + Expect(provisional.Hash).ToNot(BeEmpty()) + provisionalBytes := readAll(provisional) + Expect(len(provisionalBytes)).To(BeNumerically(">", 0)) + + _, err = artRepo.GetItemArtwork("mf", "mf1", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound), "provisional serving must not write a state row") + + // The provisional read enqueued a Bump; drain it and confirm the persisted hash matches. + runWorkerUntil(ctx, worker, itemFound("mf", "mf1")) + ia, err := artRepo.GetItemArtwork("mf", "mf1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("embedded")) + Expect(ia.Hash).To(Equal(provisional.Hash)) + + // Second read: now served from the persisted state row / store, same bytes. + resolved, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf1"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(resolved.Hash).To(Equal(ia.Hash)) + Expect(readAll(resolved)).To(Equal(provisionalBytes)) + }) + + It("records an absent state for an entity with no art and reports it unavailable", func() { + albumRepo.SetData(model.Albums{{ID: "alx", Name: "Artless", LibraryID: 0}}) + worker.Bump("al", "alx") + runWorkerUntil(ctx, worker, itemAbsent("al", "alx")) + + _, err := svc.Get(ctx, model.MustParseArtworkID("al-alx"), 0, false) + Expect(err).To(MatchError(artwork.ErrUnavailable)) + + img, err := svc.GetOrPlaceholder(ctx, "al-alx", 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(img.Placeholder).To(BeTrue()) + }) +}) diff --git a/core/artwork/e2e/e2e_suite_test.go b/core/artwork/e2e/e2e_suite_test.go new file mode 100644 index 000000000..15b3eb6e9 --- /dev/null +++ b/core/artwork/e2e/e2e_suite_test.go @@ -0,0 +1,93 @@ +// Package e2e exercises the artwork pipeline end to end against the NEW acquisition→serve +// path: it enqueues real entities, drives the real Worker to drain the queue, and serves the +// result through the real Service, over a real ImageStore and real library files. It replaces +// the legacy core/artwork/e2e suite that was removed with the old readers in the serving cutover. +package e2e + +import ( + "context" + "io" + "os" + "path/filepath" + "testing" + "time" + + _ "github.com/navidrome/navidrome/adapters/gotaglib" // registers the "taglib" local-storage extractor + "github.com/navidrome/navidrome/core/artwork" + _ "github.com/navidrome/navidrome/core/storage/local" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestArtworkE2E(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Artwork Pipeline E2E Suite") +} + +// Fixtures relative to the project root (tests.Init chdirs there). +const ( + coverFixture = "tests/fixtures/artist/an-album/cover.jpg" + mp3Fixture = "tests/fixtures/artist/an-album/test.mp3" + artistPngFixture = "tests/fixtures/artist/an-album/artist.png" + albumFolderPath = "tests/fixtures/artist/an-album" +) + +// readFixture returns the raw bytes of a project-relative fixture file. +func readFixture(rel string) []byte { + GinkgoHelper() + data, err := os.ReadFile(rel) + Expect(err).ToNot(HaveOccurred(), "reading fixture %q", rel) + return data +} + +// readAll drains an artwork image to bytes and closes it. +func readAll(img *artwork.Image) []byte { + GinkgoHelper() + Expect(img).ToNot(BeNil()) + defer img.Close() + data, err := io.ReadAll(img) + Expect(err).ToNot(HaveOccurred()) + return data +} + +// runWorkerUntil starts the real worker loop, waits for a condition, then cancels and joins it, +// mirroring how cmd drives Worker.Run in production. +func runWorkerUntil(ctx context.Context, worker *artwork.Worker, until func() bool) { + GinkgoHelper() + runCtx, cancel := context.WithCancel(ctx) + done := make(chan error, 1) + go func() { done <- worker.Run(runCtx) }() + Eventually(until, 5*time.Second, 10*time.Millisecond).Should(BeTrue()) + cancel() + Eventually(done, 2*time.Second).Should(Receive(BeNil())) +} + +// fakeFolderRepo is the minimal FolderRepository the album/playlist resolution chains touch: +// GetAll yields the seeded folders and the album-root parent lookup finds nothing. +type fakeFolderRepo struct { + model.FolderRepository + result []model.Folder +} + +func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) { return f.result, nil } + +func (f *fakeFolderRepo) HasAudioOutsideFolders(model.Folder, []string) (bool, error) { + return false, nil +} + +func (f *fakeFolderRepo) Get(string) (*model.Folder, error) { return nil, model.ErrNotFound } + +// writeUpload copies a fixture into the per-entity upload folder under the data dir and returns +// the bare filename UploadedImagePath expects. +func writeUpload(entityType, name, srcFixture string) string { + GinkgoHelper() + dst := model.UploadedImagePath(entityType, name) + Expect(os.MkdirAll(filepath.Dir(dst), 0o755)).To(Succeed()) + Expect(os.WriteFile(dst, readFixture(srcFixture), 0o600)).To(Succeed()) + return name +} diff --git a/server/subsonic/e2e/subsonic_artwork_test.go b/server/subsonic/e2e/subsonic_artwork_test.go new file mode 100644 index 000000000..4023ad03e --- /dev/null +++ b/server/subsonic/e2e/subsonic_artwork_test.go @@ -0,0 +1,308 @@ +package e2e + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "time" + + "github.com/Masterminds/squirrel" + _ "github.com/navidrome/navidrome/adapters/gotaglib" // registers the "taglib" local-storage extractor + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/lyrics" + "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/core/playback" + "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/scrobbler" + _ "github.com/navidrome/navidrome/core/storage/local" // registers the "file" storage scheme + "github.com/navidrome/navidrome/core/stream" + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/persistence" + "github.com/navidrome/navidrome/resources" + "github.com/navidrome/navidrome/scanner" + "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/server/public" + "github.com/navidrome/navidrome/server/subsonic" + "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/tests/harness" + "github.com/navidrome/navidrome/utils/cache" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.senan.xyz/taglib" +) + +// The artwork serving path streams folder-backed originals via os.Open, which a fake FS cannot +// back, so this suite scans a small REAL on-disk library and drives the real acquisition worker +// and artwork.Service through the Subsonic and public image handlers. It restores the Task 11 +// full-pipeline coverage (album found/absent + suffix transition + share/img) against the new path. +var _ = Describe("Artwork Serving", Ordered, func() { + var ( + artRouter *subsonic.Router + pubRouter *public.Router + artSvc artwork.Service + worker *artwork.Worker + artfulID string + artlessID string + artfulHash string + placeholder []byte + ) + + albumCoverInList := func(albumID string) string { + GinkgoHelper() + resp := doReq("getAlbumList2", "type", "alphabeticalByName", "size", "500") + Expect(resp.AlbumList2).ToNot(BeNil()) + for _, al := range resp.AlbumList2.Album { + if al.Id == albumID { + return al.CoverArt + } + } + Fail("album " + albumID + " not found in getAlbumList2") + return "" + } + + getCover := func(params ...string) *httptest.ResponseRecorder { + GinkgoHelper() + w := httptest.NewRecorder() + r := buildReq(adminUser, "getCoverArt", params...) + artRouter.ServeHTTP(w, r) + return w + } + + BeforeAll(func() { + DeferCleanup(configtest.SetupConfig()) + DeferCleanup(func() { Eventually(scanner.IsScanning).Should(BeFalse()) }) + ctx = request.WithUser(GinkgoT().Context(), adminUser) + + musicDir := GinkgoT().TempDir() + writeArtworkTrack(musicDir, "Artful Artist", "Artful Album", "01 - Come Together", true) + writeArtworkTrack(musicDir, "Artless Artist", "Artless Album", "01 - Lonely", false) + + conf.Server.MusicFolder = musicDir + conf.Server.DevExternalScanner = false + conf.Server.DevEnableMediaFileProbe = false + conf.Server.CoverArtPriority = "cover.jpg" + conf.Server.ArtistArtPriority = "artist.png" // offline: artists resolve absent, out of scope here + conf.Server.EnableMediaFileCoverArt = false + conf.Server.DevArtworkWorkerConcurrency = 1 + conf.Server.CacheFolder = conf.NewDir(GinkgoT().TempDir()) + conf.Server.EnableSharing = true + conf.Server.DevArtworkMaxRequests = 100 + conf.Server.DevArtworkThrottleBacklogLimit = 100 + conf.Server.DevArtworkThrottleBacklogTimeout = time.Minute + + goldenDB.Restore() + ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} + auth.Init(ds) + + // A re-scan of already-populated data deadlocks the parallel album-refresh phase at the + // harness's MaxOpenConns=1, so wipe the golden content and import ONLY the real artwork + // library fresh (the import path never enters that refresh phase). + wipeScannedContent() + artLib := model.Library{Name: "Artwork Library", Path: musicDir} + Expect(ds.Library(ctx).Put(&artLib)).To(Succeed()) + Expect(ds.User(ctx).SetUserLibraries(adminUser.ID, []int{artLib.ID})).To(Succeed()) + + s := scanner.New(ctx, ds, events.NoopBroker(), + playlists.NewPlaylists(ds, core.NewImageUploadService(ds)), metrics.NewNoopInstance()) + _, err := s.ScanAll(ctx, true) + Expect(err).ToNot(HaveOccurred()) + + // Focus the worker on the entities under test. + _, err = db.Db().Exec("DELETE FROM artwork_queue") + Expect(err).ToNot(HaveOccurred()) + + artfulID = albumIDByName("Artful Album") + artlessID = albumIDByName("Artless Album") + + ph, err := resources.FS().Open(consts.PlaceholderAlbumArt) + Expect(err).ToNot(HaveOccurred()) + placeholder, err = io.ReadAll(ph) + Expect(err).ToNot(HaveOccurred()) + _ = ph.Close() + + store := artwork.NewImageStore(GinkgoT().TempDir()) + imgCache := newDummyImageCache(ctx) + ffm := harness.NoopFFmpeg{} + artSvc = artwork.NewService(ds, imgCache, store, ffm) + worker = artwork.NewWorker(ds, store, agents.GetAgents(ds, nil), ffm, events.NoopBroker(), imgCache) + + artRouter = buildArtworkRouter(artSvc) + router = artRouter // so the shared doReq/doRawReq helpers hit the artwork-wired router + pubRouter = public.New(ds, artSvc, streamerSpy, core.NewShare(ds), noopArchiver{}) + }) + + It("emits a bare optimistic coverArt id before the queue is drained", func() { + Expect(albumCoverInList(artfulID)).To(Equal("al-" + artfulID)) + Expect(albumCoverInList(artlessID)).To(Equal("al-" + artlessID)) + }) + + It("drains the queue: folder art is acquired, the artless album settles absent", func() { + worker.Bump("al", artfulID) + worker.Bump("al", artlessID) + runWorkerUntil(ctx, worker, func() bool { + found, err := ds.Artwork(ctx).GetItemArtwork("al", artfulID, model.ImageTypePrimary) + if err != nil || found.Hash == "" { + return false + } + absent, err := ds.Artwork(ctx).GetItemArtwork("al", artlessID, model.ImageTypePrimary) + return err == nil && absent.Hash == "" + }) + ia, err := ds.Artwork(ctx).GetItemArtwork("al", artfulID, model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("folder")) + artfulHash = ia.Hash + Expect(artfulHash).ToNot(BeEmpty()) + }) + + It("promotes the list id to the hash suffix and omits the absent album's coverArt", func() { + Expect(albumCoverInList(artfulID)).To(Equal("al-" + artfulID + "_" + artfulHash)) + Expect(albumCoverInList(artlessID)).To(BeEmpty()) + }) + + It("serves the found album's bytes with revalidation headers for a bare id", func() { + w := getCover("id", "al-"+artfulID) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.Bytes()).To(Equal(readArtworkFixture("cover.jpg"))) + Expect(w.Header().Get("ETag")).To(Equal(`"` + artfulHash + `"`)) + Expect(w.Header().Get("Cache-Control")).To(Equal("public, no-cache")) + }) + + It("serves the found album immutably when the exact hash is requested", func() { + w := getCover("id", "al-"+artfulID+"_"+artfulHash) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.Bytes()).To(Equal(readArtworkFixture("cover.jpg"))) + Expect(w.Header().Get("Cache-Control")).To(Equal("public, max-age=31536000, immutable")) + }) + + It("returns 304 with an empty body when the client already holds the hash", func() { + w := httptest.NewRecorder() + r := buildReq(adminUser, "getCoverArt", "id", "al-"+artfulID) + r.Header.Set("If-None-Match", `"`+artfulHash+`"`) + artRouter.ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusNotModified)) + Expect(w.Body.Bytes()).To(BeEmpty()) + }) + + It("serves the placeholder with no-store for an absent album", func() { + w := getCover("id", "al-"+artlessID) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.Bytes()).To(Equal(placeholder)) + Expect(w.Header().Get("Cache-Control")).To(Equal("no-store")) + }) + + It("serves /share/img immutably for a JWT whose payload carries the hash", func() { + token, err := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), + auth.Claims{ID: "al-" + artfulID + "_" + artfulHash}) + Expect(err).ToNot(HaveOccurred()) + + w := httptest.NewRecorder() + pubRouter.ServeHTTP(w, httptest.NewRequest("GET", "/img/"+token, nil)) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.Bytes()).To(Equal(readArtworkFixture("cover.jpg"))) + Expect(w.Header().Get("Cache-Control")).To(Equal("public, max-age=31536000, immutable")) + }) + + It("returns 404 from /share/img for an absent entity", func() { + token, err := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), + auth.Claims{ID: "al-" + artlessID}) + Expect(err).ToNot(HaveOccurred()) + + w := httptest.NewRecorder() + pubRouter.ServeHTTP(w, httptest.NewRequest("GET", "/img/"+token, nil)) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) +}) + +// buildArtworkRouter mirrors setupTestDB's Subsonic wiring but with the real artwork.Service. +func buildArtworkRouter(art artwork.Service) *subsonic.Router { + decider := stream.NewTranscodeDecider(ds, harness.NoopFFmpeg{}) + s := scanner.New(ctx, ds, events.NoopBroker(), + playlists.NewPlaylists(ds, core.NewImageUploadService(ds)), metrics.NewNoopInstance()) + return subsonic.New( + ds, art, streamerSpy, noopArchiver{}, core.NewPlayers(ds), noopProvider{}, s, + events.NoopBroker(), playlists.NewPlaylists(ds, core.NewImageUploadService(ds)), + scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), core.NewShare(ds), + playback.PlaybackServer(nil), metrics.NewNoopInstance(), lyrics.NewLyrics(ds, nil), decider, nil, + ) +} + +// wipeScannedContent clears all scanned library content (including the golden fake library) so the +// next scan is a clean import. FKs are disabled for the bulk delete, mirroring harness.Restore. +func wipeScannedContent() { + GinkgoHelper() + _, err := db.Db().Exec("PRAGMA foreign_keys = OFF") + Expect(err).ToNot(HaveOccurred()) + for _, t := range []string{"media_file", "album", "artist", "folder", "item_artwork", "artwork_queue", "artwork", "library"} { + _, err = db.Db().Exec(`DELETE FROM "` + t + `"`) + Expect(err).ToNot(HaveOccurred(), "wiping %s", t) + } + _, err = db.Db().Exec("PRAGMA foreign_keys = ON") + Expect(err).ToNot(HaveOccurred()) +} + +func albumIDByName(name string) string { + GinkgoHelper() + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"album.name": name}}) + Expect(err).ToNot(HaveOccurred()) + Expect(albums).To(HaveLen(1), "expected exactly one album named %q", name) + return albums[0].ID +} + +// writeArtworkTrack lays down one tag-distinct track (and optionally a folder cover) so the scanner +// creates a self-contained album; distinct ALBUM/ALBUMARTIST tags keep the two albums from merging. +func writeArtworkTrack(root, artist, album, title string, withCover bool) { + GinkgoHelper() + dir := filepath.Join(root, artist, album) + Expect(os.MkdirAll(dir, 0o755)).To(Succeed()) + mp3 := filepath.Join(dir, title+".mp3") + Expect(os.WriteFile(mp3, readArtworkFixture("test.mp3"), 0o600)).To(Succeed()) + Expect(taglib.WriteTags(mp3, map[string][]string{ + "ALBUM": {album}, "ALBUMARTIST": {artist}, "ARTIST": {artist}, "TITLE": {title}, + }, taglib.Clear)).To(Succeed()) + if withCover { + Expect(os.WriteFile(filepath.Join(dir, "cover.jpg"), readArtworkFixture("cover.jpg"), 0o600)).To(Succeed()) + } +} + +func readArtworkFixture(name string) []byte { + GinkgoHelper() + data, err := os.ReadFile(filepath.Join("tests", "fixtures", "artist", "an-album", name)) + Expect(err).ToNot(HaveOccurred()) + return data +} + +// newDummyImageCache backs the artwork.Service's resize cache. size=0 requests stream originals +// and never invoke the reader, so it only needs to satisfy the constructor; resize behavior is +// covered by the artwork package's own suites. +func newDummyImageCache(ctx context.Context) cache.FileCache { + GinkgoHelper() + c := cache.NewFileCache("SubsonicArtworkE2E", "100MB", "images", 0, + func(context.Context, cache.Item) (io.Reader, error) { + return nil, errors.New("resize not exercised in subsonic artwork e2e") + }) + Eventually(func() bool { return c.Available(ctx) }).Should(BeTrue()) + return c +} + +// runWorkerUntil drives the real worker loop until a condition holds, then cancels and joins it. +func runWorkerUntil(ctx context.Context, worker *artwork.Worker, until func() bool) { + GinkgoHelper() + runCtx, cancel := context.WithCancel(ctx) + done := make(chan error, 1) + go func() { done <- worker.Run(runCtx) }() + Eventually(until, 10*time.Second, 20*time.Millisecond).Should(BeTrue()) + cancel() + Eventually(done, 2*time.Second).Should(Receive(BeNil())) +} From c2d7ae773c37d1a8ad0e620fe9051ae35edf9b18 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 23:46:31 -0400 Subject: [PATCH 67/87] chore(artwork): generic 500 bodies on refresh endpoint, trim stale test comments --- core/artwork/e2e/e2e_suite_test.go | 7 +++---- server/nativeapi/artwork.go | 4 ++-- server/subsonic/e2e/subsonic_artwork_test.go | 3 +-- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/core/artwork/e2e/e2e_suite_test.go b/core/artwork/e2e/e2e_suite_test.go index 15b3eb6e9..bec7b12cf 100644 --- a/core/artwork/e2e/e2e_suite_test.go +++ b/core/artwork/e2e/e2e_suite_test.go @@ -1,7 +1,6 @@ -// Package e2e exercises the artwork pipeline end to end against the NEW acquisition→serve -// path: it enqueues real entities, drives the real Worker to drain the queue, and serves the -// result through the real Service, over a real ImageStore and real library files. It replaces -// the legacy core/artwork/e2e suite that was removed with the old readers in the serving cutover. +// Package e2e exercises the artwork pipeline end to end: it enqueues real entities, drives the +// real Worker to drain the queue, and serves the result through the real Service, over a real +// ImageStore and real library files. package e2e import ( diff --git a/server/nativeapi/artwork.go b/server/nativeapi/artwork.go index cefbccff4..0a07014be 100644 --- a/server/nativeapi/artwork.go +++ b/server/nativeapi/artwork.go @@ -34,14 +34,14 @@ func (api *Router) refreshArtwork() http.HandlerFunc { } if err := api.ds.Artwork(ctx).DeleteForItem(kind, id); err != nil { log.Error(ctx, "Error clearing artwork state", "kind", kind, "id", id, err) - http.Error(w, err.Error(), http.StatusInternalServerError) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } item := model.ArtworkQueueItem{ItemKind: kind, ItemID: id, ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBump} if err := api.ds.ArtworkQueue(ctx).Enqueue(item); err != nil { log.Error(ctx, "Error enqueuing artwork refresh", "kind", kind, "id", id, err) - http.Error(w, err.Error(), http.StatusInternalServerError) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) diff --git a/server/subsonic/e2e/subsonic_artwork_test.go b/server/subsonic/e2e/subsonic_artwork_test.go index 4023ad03e..fc4bec8a0 100644 --- a/server/subsonic/e2e/subsonic_artwork_test.go +++ b/server/subsonic/e2e/subsonic_artwork_test.go @@ -45,8 +45,7 @@ import ( // The artwork serving path streams folder-backed originals via os.Open, which a fake FS cannot // back, so this suite scans a small REAL on-disk library and drives the real acquisition worker -// and artwork.Service through the Subsonic and public image handlers. It restores the Task 11 -// full-pipeline coverage (album found/absent + suffix transition + share/img) against the new path. +// and artwork.Service through the Subsonic and public image handlers. var _ = Describe("Artwork Serving", Ordered, func() { var ( artRouter *subsonic.Router From ca4220b0290d51162e48a982c6dc87818c7ececd Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 23 Jul 2026 00:26:46 -0400 Subject: [PATCH 68/87] fix(artwork): request read-through must not reset the failure backoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The provisional read-through and dangling re-enqueue used Enqueue, whose upsert resets retry_at, so any browse of an unresolved entity that was backing off after an external failure made it immediately eligible again — defeating the exponential backoff during a provider outage. Add EnqueueBump, which raises priority but leaves an existing row's retry_at intact, and route the serving path through it. Scan and manual re-resolve keep Enqueue's reset (a detected change wants immediate retry). --- core/artwork/serving.go | 4 ++- model/artwork.go | 6 ++++- persistence/artwork_queue_repository.go | 15 +++++++++-- persistence/artwork_queue_repository_test.go | 24 ++++++++++++++++++ tests/mock_artwork_queue_repo.go | 26 ++++++++++++++++++++ 5 files changed, 71 insertions(+), 4 deletions(-) diff --git a/core/artwork/serving.go b/core/artwork/serving.go index 38a474355..109d0033d 100644 --- a/core/artwork/serving.go +++ b/core/artwork/serving.go @@ -286,8 +286,10 @@ func (s *service) dangling(ctx context.Context, artID model.ArtworkID) (*Image, return nil, ErrUnavailable } +// enqueue schedules a request-triggered re-resolution. It uses EnqueueBump so an incidental +// read-through never resets a failed resolution's backoff (unlike scan/manual re-resolve). func (s *service) enqueue(ctx context.Context, artID model.ArtworkID, priority int) { - err := s.ds.ArtworkQueue(ctx).Enqueue(model.ArtworkQueueItem{ + err := s.ds.ArtworkQueue(ctx).EnqueueBump(model.ArtworkQueueItem{ ItemKind: artID.Kind.Prefix(), ItemID: artID.ID, ImageType: model.ImageTypePrimary, diff --git a/model/artwork.go b/model/artwork.go index 88b2d009d..6d343946e 100644 --- a/model/artwork.go +++ b/model/artwork.go @@ -91,8 +91,12 @@ type ArtworkRepository interface { } type ArtworkQueueRepository interface { - // Enqueue upserts; an existing row keeps the higher of the two priorities. + // Enqueue upserts; an existing row keeps the higher of the two priorities and has its + // retry_at reset (a detected change wants immediate re-resolution). Enqueue(items ...ArtworkQueueItem) error + // EnqueueBump upserts like Enqueue but preserves an existing row's retry_at, so a + // request-triggered read-through never resets a failed resolution's backoff. + EnqueueBump(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. diff --git a/persistence/artwork_queue_repository.go b/persistence/artwork_queue_repository.go index 59e130204..81f674dd9 100644 --- a/persistence/artwork_queue_repository.go +++ b/persistence/artwork_queue_repository.go @@ -26,6 +26,18 @@ func NewArtworkQueueRepository(ctx context.Context, db dbx.Builder) model.Artwor } func (r *artworkQueueRepository) Enqueue(items ...model.ArtworkQueueItem) error { + return r.enqueue(`ON CONFLICT (item_kind, item_id, image_type) DO UPDATE SET + priority = MAX(priority, excluded.priority), retry_at = excluded.retry_at`, items) +} + +// EnqueueBump raises priority like Enqueue but leaves an existing row's retry_at intact, so a +// request-triggered read-through never resets a failed resolution's backoff. New rows insert eligible. +func (r *artworkQueueRepository) EnqueueBump(items ...model.ArtworkQueueItem) error { + return r.enqueue(`ON CONFLICT (item_kind, item_id, image_type) DO UPDATE SET + priority = MAX(priority, excluded.priority)`, items) +} + +func (r *artworkQueueRepository) enqueue(conflict string, items []model.ArtworkQueueItem) error { now := time.Now() for chunk := range slices.Chunk(items, enqueueChunkSize) { ins := Insert(r.tableName).Columns("item_kind", "item_id", "image_type", "priority", "attempts", "retry_at", "enqueued_at") @@ -35,8 +47,7 @@ func (r *artworkQueueRepository) Enqueue(items ...model.ArtworkQueueItem) error } ins = ins.Values(it.ItemKind, it.ItemID, it.ImageType, it.Priority, 0, now, now) } - ins = ins.Suffix(`ON CONFLICT (item_kind, item_id, image_type) DO UPDATE SET - priority = MAX(priority, excluded.priority), retry_at = excluded.retry_at`) + ins = ins.Suffix(conflict) if _, err := r.executeSQL(ins); err != nil { return err } diff --git a/persistence/artwork_queue_repository_test.go b/persistence/artwork_queue_repository_test.go index 9a4054cb9..f5eafec00 100644 --- a/persistence/artwork_queue_repository_test.go +++ b/persistence/artwork_queue_repository_test.go @@ -41,6 +41,30 @@ var _ = Describe("ArtworkQueueRepository", func() { Expect(got[0].Priority).To(Equal(model.ArtworkPriorityBump)) }) + It("EnqueueBump raises priority without resetting a backing-off row's retry_at", func() { + Expect(repo.Enqueue(item("al", "b1", model.ArtworkPriorityScan))).To(Succeed()) + // Push retry_at into the future so the row is backing off and hidden from dequeue. + Expect(repo.MarkFailed("al", "b1", model.ImageTypePrimary, time.Now().Add(time.Hour))).To(Succeed()) + Expect(repo.DequeueBatch(10)).To(BeEmpty()) + + // A request-triggered bump raises priority but must leave the backoff intact. + Expect(repo.EnqueueBump(item("al", "b1", model.ArtworkPriorityBump))).To(Succeed()) + Expect(repo.DequeueBatch(10)).To(BeEmpty(), "bump must not reset retry_at") + + // Enqueue (scan/manual), by contrast, resets retry_at and makes it eligible now. + Expect(repo.Enqueue(item("al", "b1", model.ArtworkPriorityScan))).To(Succeed()) + got, _ := repo.DequeueBatch(10) + Expect(got).To(HaveLen(1)) + Expect(got[0].Priority).To(Equal(model.ArtworkPriorityBump), "bump's higher priority is preserved") + }) + + It("EnqueueBump inserts a brand-new row eligible immediately", func() { + Expect(repo.EnqueueBump(item("ar", "n1", model.ArtworkPriorityBump))).To(Succeed()) + got, _ := repo.DequeueBatch(10) + Expect(got).To(HaveLen(1)) + Expect(got[0].ItemID).To(Equal("n1")) + }) + It("hides failed items until retry_at", func() { Expect(repo.Enqueue(item("al", "f1", model.ArtworkPriorityScan))).To(Succeed()) Expect(repo.MarkFailed("al", "f1", model.ImageTypePrimary, time.Now().Add(time.Hour))).To(Succeed()) diff --git a/tests/mock_artwork_queue_repo.go b/tests/mock_artwork_queue_repo.go index 500fdf4fd..cbb2f3f58 100644 --- a/tests/mock_artwork_queue_repo.go +++ b/tests/mock_artwork_queue_repo.go @@ -160,6 +160,32 @@ func (m *MockArtworkQueueRepo) Count() (int64, error) { return int64(len(m.Data)), nil } +func (m *MockArtworkQueueRepo) EnqueueBump(items ...model.ArtworkQueueItem) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return m.Err + } + now := time.Now() + for _, it := range items { + if it.ImageType == "" { + it.ImageType = model.ImageTypePrimary + } + k := iaKey(it.ItemKind, it.ItemID, it.ImageType) + // Preserve an existing row's retry_at: a bump raises priority without resetting backoff. + if prev, ok := m.Data[k]; ok { + prev.Priority = max(prev.Priority, it.Priority) + m.Data[k] = prev + continue + } + it.Attempts = 0 + it.RetryAt = now + it.EnqueuedAt = now + m.Data[k] = it + } + return nil +} + func (m *MockArtworkQueueRepo) EnqueueStaleAbsent(kind string, attemptedBefore time.Time) (int64, error) { m.mu.Lock() defer m.mu.Unlock() From 0781c4a9b2dad51a243ed800e7e5c970998e8b6d Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 23 Jul 2026 00:26:47 -0400 Subject: [PATCH 69/87] fix(artwork): keep an eligible track's cover requestable when its album is absent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An embedded-eligible track with no resolved item_artwork row inherited the album's ImageAbsent, so when the album resolved absent (e.g. CoverArtPriority without 'embedded') the track's coverArt was omitted permanently — the client never requested it, so the lazy mediafile path never resolved it — even though the serving path would extract and serve the track's own embedded art. Hydration now never copies the album's absence onto an eligible-but-unresolved track. --- persistence/artwork_hydration_test.go | 26 ++++++++++++++++++++++++++ persistence/mediafile_repository.go | 16 +++++++++++++--- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/persistence/artwork_hydration_test.go b/persistence/artwork_hydration_test.go index 6001221cb..5dab6f2de 100644 --- a/persistence/artwork_hydration_test.go +++ b/persistence/artwork_hydration_test.go @@ -233,6 +233,32 @@ var _ = Describe("Artwork hydration", func() { Expect(byID["2002"].ImageAbsent).To(BeFalse()) }) + It("keeps an eligible file optimistic when its own art is unresolved, even if the album is absent", func() { + // 1004 is eligible (has embedded cover) with no mf state row yet; its album (103) is absent. + setCover("1004", true) + DeferCleanup(func() { setCover("1004", false) }) + putInfo("al", "103", "") // album known-absent + + byID := getByID() + + // The track's own embedded art is still unresolved, so it must NOT inherit the album's + // absence: coverArt stays requestable so serving can extract the embedded art. + Expect(byID["1004"].ImageAbsent).To(BeFalse()) + Expect(byID["1004"].ImageHash).To(BeEmpty()) + // A non-eligible sibling on the same absent album still inherits the absence. + Expect(byID["1003"].ImageAbsent).To(BeTrue()) + }) + + It("uses the album hash for an eligible file whose own art is unresolved but album is found", func() { + setCover("1004", true) + DeferCleanup(func() { setCover("1004", false) }) + putInfo("al", "103", "alh103found11111") + + byID := getByID() + Expect(byID["1004"].ImageAbsent).To(BeFalse()) + Expect(byID["1004"].ImageHash).To(Equal("alh103found11111")) + }) + It("uses album info for an eligible file when EnableMediaFileCoverArt is off", func() { conf.Server.EnableMediaFileCoverArt = false setCover("1001", true) diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index 9ba9d3825..72c1907d5 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -241,11 +241,21 @@ func (r *mediaFileRepository) hydrateArtwork(mfs model.MediaFiles) { mfInfos := hydrateItemImages(r.ctx, r.db, model.KindMediaFileArtwork.Prefix(), eligibleIDs) for i := range mfs { if mfs[i].HasCoverArt && conf.Server.EnableMediaFileCoverArt { - if info, ok := mfInfos[mfs[i].ID]; ok && !info.Absent() { - mfs[i].ImageHash = info.Hash - mfs[i].ImageAbsent = false + if info, ok := mfInfos[mfs[i].ID]; ok { + if !info.Absent() { + mfs[i].ImageHash = info.Hash + continue + } + // Own art resolved absent → serving delegates to the album, so mirror that. + applyItemImage(albumInfos, mfs[i].AlbumID, &mfs[i].ItemImage) continue } + // Eligible but unresolved: take a found album hash for caching, but never the + // album's absence — serving still extracts the track's own embedded art on request. + if albumInfo, ok := albumInfos[mfs[i].AlbumID]; ok && !albumInfo.Absent() { + mfs[i].ImageHash = albumInfo.Hash + } + continue } applyItemImage(albumInfos, mfs[i].AlbumID, &mfs[i].ItemImage) } From d15d85ad0cd5f9d4650d220ba6a808c8eb89a61e Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 23 Jul 2026 00:43:03 -0400 Subject: [PATCH 70/87] fix(artwork): validate each agent image URL before picking the largest bestImageURL selected the largest by size and only then parsed it, so a malformed largest URL (e.g. a bad percent-escape) returned nil and shadowed a valid smaller candidate, contradicting the documented skip-unparseable behavior. Parse per candidate and compare sizes only among URLs that parse. --- core/artwork/agent_images.go | 23 +++++++++++------------ core/artwork/agent_images_test.go | 9 +++++++++ 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/core/artwork/agent_images.go b/core/artwork/agent_images.go index 029235830..95991629f 100644 --- a/core/artwork/agent_images.go +++ b/core/artwork/agent_images.go @@ -25,25 +25,24 @@ func denyGate(_ string, _ func() (io.ReadCloser, string, error)) (io.ReadCloser, } // bestImageURL returns the largest-Size image URL, skipping empty or unparseable -// URLs; nil when none qualifies. +// URLs; nil when none qualifies. Parsing happens per candidate so a malformed largest +// URL never shadows a valid smaller one. func bestImageURL(imgs []agents.ExternalImage) *url.URL { - var best *agents.ExternalImage + var best *url.URL + var bestSize int for i := range imgs { if imgs[i].URL == "" { continue } - if best == nil || imgs[i].Size > best.Size { - best = &imgs[i] + u, err := url.Parse(imgs[i].URL) + if err != nil { + continue + } + if best == nil || imgs[i].Size > bestSize { + best, bestSize = u, imgs[i].Size } } - if best == nil { - return nil - } - u, err := url.Parse(best.URL) - if err != nil { - return nil - } - return u + return best } // fetchArtistImage tries each enabled artist-image agent in order, each under its own gate. diff --git a/core/artwork/agent_images_test.go b/core/artwork/agent_images_test.go index f90863aed..0bcd7667a 100644 --- a/core/artwork/agent_images_test.go +++ b/core/artwork/agent_images_test.go @@ -82,6 +82,15 @@ var _ = Describe("agent images", func() { Expect(u.String()).To(Equal("http://x/big")) }) + It("skips a malformed largest URL and falls back to a valid smaller one", func() { + u := bestImageURL([]agents.ExternalImage{ + {URL: "http://x/valid", Size: 10}, + {URL: "http://x/%zz", Size: 100}, // invalid percent-escape, largest + }) + Expect(u).ToNot(BeNil()) + Expect(u.String()).To(Equal("http://x/valid")) + }) + It("returns nil when there is no non-empty URL", func() { Expect(bestImageURL(nil)).To(BeNil()) Expect(bestImageURL([]agents.ExternalImage{{URL: "", Size: 5}})).To(BeNil()) From f4e14e9c1a8dbcbfdf13bcd51fbbdb82e38cf52b Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 23 Jul 2026 00:43:04 -0400 Subject: [PATCH 71/87] fix(artwork): fall back to disc art, not the album, for multi-disc tracks serveMediaFile delegated an absent/ineligible track straight to AlbumCoverArtID, skipping the disc-specific lookup that MediaFile.CoverArtID (and the deleted legacy reader) use. On multi-disc albums with per-disc images that served the album cover instead of the configured disc artwork. Delegate through DiscCoverArtID. --- core/artwork/serving.go | 4 +++- core/artwork/serving_test.go | 13 +++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/core/artwork/serving.go b/core/artwork/serving.go index 109d0033d..97c46fc9b 100644 --- a/core/artwork/serving.go +++ b/core/artwork/serving.go @@ -244,7 +244,9 @@ func (s *service) serveMediaFile(ctx context.Context, artID model.ArtworkID, siz if noRow && conf.Server.EnableMediaFileCoverArt && mf.HasCoverArt { return s.provisionalEmbedded(ctx, artID, *mf, size, square) } - return s.Get(ctx, mf.AlbumCoverArtID(), size, square) + // Mirror MediaFile.CoverArtID's fallback: a multi-disc track defers to its disc art + // (which itself falls back to the album), not straight to the album. + return s.Get(ctx, mf.DiscCoverArtID(), size, square) } // provisionalEmbedded extracts a track's embedded art for an immediate serve and always diff --git a/core/artwork/serving_test.go b/core/artwork/serving_test.go index 529cd5e24..b71ae1e05 100644 --- a/core/artwork/serving_test.go +++ b/core/artwork/serving_test.go @@ -253,6 +253,19 @@ var _ = Describe("Service", func() { _, err = artRepo.GetItemArtwork("mf", "mf4", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound)) }) + + It("delegates a multi-disc track to its disc art, not straight to the album", func() { + // Not embedded-eligible: the fallback must mirror CoverArtID (disc first, then album). + folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}} + albumRepo.SetData(model.Albums{{ID: "aldd", Name: "Album", FolderIDs: []string{"f1"}}}) + seedFoundStore("al", "aldd", []byte("album-art-distinct")) // album's own found art differs + mfRepo.SetData(model.MediaFiles{{ID: "mf5", AlbumID: "aldd", DiscNumber: 1, HasCoverArt: false}}) + + img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf5"), 0, false) + Expect(err).ToNot(HaveOccurred()) + // The disc-folder image wins over the album's found art, proving it routed via serveDisc. + Expect(readAll(img)).To(Equal(coverBytes)) + }) }) Describe("disc", func() { From 66d3d231490156b94897a7df161c976246e62fd5 Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 23 Jul 2026 00:43:04 -0400 Subject: [PATCH 72/87] fix(artwork): enqueue uploaded artwork only after the filename is persisted SetImage cleared state and enqueued the bump before the caller stored the new filename, so a worker drain in that window could resolve against the old (already deleted) file and settle absent, leaving the upload unused until a later scan. Move the invalidate+enqueue into EnqueueArtwork, which each caller now invokes after the entity Put. --- core/image_upload.go | 9 +++++---- core/image_upload_test.go | 24 +++++++++++++++++++++++- core/playlists/playlists.go | 7 ++++++- server/nativeapi/artists.go | 6 +++++- server/nativeapi/radios.go | 6 +++++- 5 files changed, 44 insertions(+), 8 deletions(-) diff --git a/core/image_upload.go b/core/image_upload.go index 6f5ef418a..ce32f8258 100644 --- a/core/image_upload.go +++ b/core/image_upload.go @@ -18,6 +18,9 @@ import ( type ImageUploadService interface { SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (filename string, err error) RemoveImage(ctx context.Context, path string) error + // EnqueueArtwork clears an item's resolved state and re-queues it at Bump priority. Callers + // must invoke it AFTER persisting the new filename, so the worker never resolves the old one. + EnqueueArtwork(ctx context.Context, entityType, entityID string) } // MaxImageUploadSize returns the configured MaxImageUploadSize in bytes, or the built-in default @@ -71,14 +74,12 @@ func (s *imageUploadService) SetImage(ctx context.Context, entityType string, en if _, err := io.Copy(f, reader); err != nil { return "", fmt.Errorf("writing image file: %w", err) } - - s.bumpArtwork(ctx, entityType, entityID) return filename, nil } -// bumpArtwork clears the item's resolved state and re-queues it at Bump priority: the +// EnqueueArtwork clears the item's resolved state and re-queues it at Bump priority: the // upload is now the top-priority source, so the worker re-resolves and the UI swaps. -func (s *imageUploadService) bumpArtwork(ctx context.Context, entityType, id string) { +func (s *imageUploadService) EnqueueArtwork(ctx context.Context, entityType, id string) { kind, ok := uploadEntityKind[entityType] if !ok { return diff --git a/core/image_upload_test.go b/core/image_upload_test.go index 1b63b3052..29ce69298 100644 --- a/core/image_upload_test.go +++ b/core/image_upload_test.go @@ -77,7 +77,7 @@ var _ = Describe("ImageUploadService", func() { Expect(err).ToNot(HaveOccurred()) }) - It("clears artwork state and enqueues a Bump on success", func() { + It("does not touch artwork state or the queue (that is EnqueueArtwork's job, post-Put)", func() { ctx := context.Background() Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ ItemKind: "ar", ItemID: "ar-1", Hash: "oldhash", Source: "external", @@ -86,7 +86,24 @@ var _ = Describe("ImageUploadService", func() { _, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Pink Floyd", "", strings.NewReader("img"), ".jpg") Expect(err).ToNot(HaveOccurred()) + // SetImage only writes the file; the state row survives and nothing is queued until + // the caller has persisted the new filename and called EnqueueArtwork. _, err = artRepo.GetItemArtwork("ar", "ar-1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(queueRepo.DequeueBatch(1000)).To(BeEmpty()) + }) + }) + + Describe("EnqueueArtwork", func() { + It("clears artwork state and enqueues a Bump", func() { + ctx := context.Background() + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + ItemKind: "ar", ItemID: "ar-1", Hash: "oldhash", Source: "external", + })).To(Succeed()) + + svc.EnqueueArtwork(ctx, consts.EntityArtist, "ar-1") + + _, err := artRepo.GetItemArtwork("ar", "ar-1", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound)) queued, err := queueRepo.DequeueBatch(1000) @@ -97,6 +114,11 @@ var _ = Describe("ImageUploadService", func() { HaveField("Priority", model.ArtworkPriorityBump), ))) }) + + It("is a no-op for an unknown entity type", func() { + svc.EnqueueArtwork(context.Background(), "unknown", "x-1") + Expect(queueRepo.DequeueBatch(1000)).To(BeEmpty()) + }) }) Describe("RemoveImage", func() { diff --git a/core/playlists/playlists.go b/core/playlists/playlists.go index 1ef083bbb..a4a26c0f5 100644 --- a/core/playlists/playlists.go +++ b/core/playlists/playlists.go @@ -57,6 +57,7 @@ type Playlists interface { type ImageUploadService interface { SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (filename string, err error) RemoveImage(ctx context.Context, path string) error + EnqueueArtwork(ctx context.Context, entityType, entityID string) } type playlists struct { @@ -320,7 +321,11 @@ func (s *playlists) SetImage(ctx context.Context, playlistID string, reader io.R } pls.UploadedImage = filename - return s.ds.Playlist(ctx).Put(pls) + if err := s.ds.Playlist(ctx).Put(pls); err != nil { + return err + } + s.imgUpload.EnqueueArtwork(ctx, consts.EntityPlaylist, pls.ID) + return nil } func (s *playlists) RemoveImage(ctx context.Context, playlistID string) error { diff --git a/server/nativeapi/artists.go b/server/nativeapi/artists.go index daa918d00..90d4c0d27 100644 --- a/server/nativeapi/artists.go +++ b/server/nativeapi/artists.go @@ -46,7 +46,11 @@ func (api *Router) uploadArtistImage() http.HandlerFunc { } ar.UploadedImage = filename ar.UpdatedAt = new(time.Now()) - return api.ds.Artist(ctx).Put(ar, "uploaded_image", "updated_at") + if err := api.ds.Artist(ctx).Put(ar, "uploaded_image", "updated_at"); err != nil { + return err + } + api.imgUpload.EnqueueArtwork(ctx, consts.EntityArtist, ar.ID) + return nil }) } diff --git a/server/nativeapi/radios.go b/server/nativeapi/radios.go index 701c6c926..3868a3501 100644 --- a/server/nativeapi/radios.go +++ b/server/nativeapi/radios.go @@ -47,7 +47,11 @@ func (api *Router) uploadRadioImage() http.HandlerFunc { return err } radio.UploadedImage = filename - return api.ds.Radio(ctx).Put(radio, "UploadedImage") + if err := api.ds.Radio(ctx).Put(radio, "UploadedImage"); err != nil { + return err + } + api.imgUpload.EnqueueArtwork(ctx, consts.EntityRadio, radio.ID) + return nil }) } From 49039fab471f4eb564df5934f1787bdd0ed3b539 Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 23 Jul 2026 05:30:46 -0400 Subject: [PATCH 73/87] fix(artwork): keep multi-disc tracks requestable when the album is absent Round-1's hydration fix still copied the album's known-absent onto a non-eligible (or own-absent) track, but MediaFile.CoverArtID routes a multi-disc track to disc art, which resolves provisionally and is never known-absent. Marking it absent made Subsonic omit coverArt so clients never requested a valid disc image. Only mark a single-disc track absent, and only when its own art won't resolve. --- persistence/artwork_hydration_test.go | 11 ++++++++ persistence/mediafile_repository.go | 38 ++++++++++++++++----------- 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/persistence/artwork_hydration_test.go b/persistence/artwork_hydration_test.go index 5dab6f2de..cb90591d9 100644 --- a/persistence/artwork_hydration_test.go +++ b/persistence/artwork_hydration_test.go @@ -249,6 +249,17 @@ var _ = Describe("Artwork hydration", func() { Expect(byID["1003"].ImageAbsent).To(BeTrue()) }) + It("keeps a multi-disc track requestable when its album is absent (disc art may resolve)", func() { + putInfo("al", "104", "") // songs 2002/2004 album known-absent + + byID := getByID() + + // 2002 is a multi-disc track (DiscNumber>0); CoverArtID points at disc art, which + // resolves provisionally, so it must never inherit the album's absence. + Expect(byID["2002"].ImageAbsent).To(BeFalse()) + Expect(byID["2002"].ImageHash).To(BeEmpty()) + }) + It("uses the album hash for an eligible file whose own art is unresolved but album is found", func() { setCover("1004", true) DeferCleanup(func() { setCover("1004", false) }) diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index 72c1907d5..401e485e8 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -240,24 +240,30 @@ func (r *mediaFileRepository) hydrateArtwork(mfs model.MediaFiles) { albumInfos := hydrateItemImages(r.ctx, r.db, model.KindAlbumArtwork.Prefix(), albumIDs) mfInfos := hydrateItemImages(r.ctx, r.db, model.KindMediaFileArtwork.Prefix(), eligibleIDs) for i := range mfs { - if mfs[i].HasCoverArt && conf.Server.EnableMediaFileCoverArt { - if info, ok := mfInfos[mfs[i].ID]; ok { - if !info.Absent() { - mfs[i].ImageHash = info.Hash - continue - } - // Own art resolved absent → serving delegates to the album, so mirror that. - applyItemImage(albumInfos, mfs[i].AlbumID, &mfs[i].ItemImage) - continue - } - // Eligible but unresolved: take a found album hash for caching, but never the - // album's absence — serving still extracts the track's own embedded art on request. - if albumInfo, ok := albumInfos[mfs[i].AlbumID]; ok && !albumInfo.Absent() { - mfs[i].ImageHash = albumInfo.Hash - } + mf := &mfs[i] + eligible := mf.HasCoverArt && conf.Server.EnableMediaFileCoverArt + ownInfo, ownResolved := mfInfos[mf.ID] + if eligible && ownResolved && !ownInfo.Absent() { + mf.ImageHash = ownInfo.Hash // own resolved art wins continue } - applyItemImage(albumInfos, mfs[i].AlbumID, &mfs[i].ItemImage) + // Fallback (see MediaFile.CoverArtID): inherit a found album hash for optimistic caching. + if album, ok := albumInfos[mf.AlbumID]; ok && !album.Absent() { + mf.ImageHash = album.Hash + continue + } + // Nothing found. Mark absent only when serving would definitively yield a placeholder: + // a single-disc track whose album is known-absent and whose own art won't resolve. A + // multi-disc track resolves disc art provisionally (never known-absent), and an + // eligible-but-unresolved track can still extract its own embedded art — both stay + // requestable. + if mf.DiscNumber > 0 { + continue + } + ownWontResolve := !eligible || (ownResolved && ownInfo.Absent()) + if album, ok := albumInfos[mf.AlbumID]; ok && album.Absent() && ownWontResolve { + mf.ImageAbsent = true + } } } From cfca4a2433d64e75a8c60c909511653d11c1f374 Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 23 Jul 2026 05:30:47 -0400 Subject: [PATCH 74/87] fix(artwork): serve a local playlist ExternalImageURL as a file-backed reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A local ExternalImageURL was resolved through the external step and labelled external, so placeBytes copied it into the content-addressed store and dropped its path/mtime — replacing the file never tripped the staleness check. Classify local references as file-backed (resolved in place, even on the request path) and keep store-backed behaviour only for http(s) URLs. --- core/artwork/playlist_cover.go | 14 -------- core/artwork/resolve.go | 60 +++++++++++++++++++--------------- core/artwork/resolve_test.go | 19 +++++++++++ 3 files changed, 52 insertions(+), 41 deletions(-) diff --git a/core/artwork/playlist_cover.go b/core/artwork/playlist_cover.go index 627a06b49..4373e2007 100644 --- a/core/artwork/playlist_cover.go +++ b/core/artwork/playlist_cover.go @@ -4,7 +4,6 @@ import ( "context" "image" "image/draw" - "io" "os" "path/filepath" "strings" @@ -16,19 +15,6 @@ import ( const tileSize = 600 -func fromLocalFile(path string) sourceFunc { - return func() (io.ReadCloser, string, error) { - if path == "" { - return nil, "", nil - } - f, err := os.Open(path) - if err != nil { - return nil, "", err - } - return f, path, nil - } -} - // findPlaylistSidecarPath scans the directory of the playlist file for a sidecar // image file with the same base name (case-insensitive). Returns empty string if // no matching image is found or if plsPath is empty. diff --git a/core/artwork/resolve.go b/core/artwork/resolve.go index 5caab5332..862d0bee7 100644 --- a/core/artwork/resolve.go +++ b/core/artwork/resolve.go @@ -201,15 +201,26 @@ func resolvePlaylist(ctx context.Context, ds model.DataStore, ag *agents.Agents, if res, ok := resolveLocalFile(findPlaylistSidecarPath(ctx, pl.Path), "folder"); ok { return res, nil } + // A local ExternalImageURL is a file-backed reference: serve it in place (staleness-checked, + // and available even on the request path). Only http(s) URLs need the gated remote fetch. + localImg, remoteImg := classifyPlaylistImage(pl.ExternalImageURL) + if localImg != "" { + if res, ok := resolveLocalFile(localImg, "folder"); ok { + return res, nil + } + } if localOnly { - // The ExternalImageURL step and the 2x2 grid are worker-only; a request must + // The remote ExternalImageURL fetch and the 2x2 grid are worker-only; a request must // not fetch remotely nor sample album art synchronously. return resolution{}, nil } - if res, ok, isErr := resolveExternalStep(gate, "m3u", fromPlaylistExternalSource(ctx, *pl)); ok { - return res, nil - } else if isErr { - extErr = true + if remoteImg != nil && conf.Server.EnableM3UExternalAlbumArt { + sf := func() (io.ReadCloser, string, error) { return fetchPlaylistImageURL(ctx, remoteImg) } + if res, ok, isErr := resolveExternalStep(gate, "m3u", sf); ok { + return res, nil + } else if isErr { + extErr = true + } } albumIDs, err := ds.Playlist(ctx).Tracks(pl.ID, false).GetAlbumIDs(model.QueryOptions{Max: 4, Sort: "random()"}) @@ -303,28 +314,23 @@ func resolveExternalStep(gate gateFunc, name string, sf sourceFunc) (res resolut return resolution{}, false, err != nil && !errors.Is(err, model.ErrNotFound) } -// fromPlaylistExternalSource mirrors reader_playlist.go's ExternalImageURL step: -// a remote URL (gated) when M3U external art is enabled, else a local file path. -func fromPlaylistExternalSource(ctx context.Context, pl model.Playlist) sourceFunc { - return func() (io.ReadCloser, string, error) { - imgURL := pl.ExternalImageURL - if imgURL == "" { - return nil, "", nil - } - parsed, err := url.Parse(imgURL) - if err != nil { - return nil, "", err - } - if parsed.Scheme == "http" || parsed.Scheme == "https" { - if !conf.Server.EnableM3UExternalAlbumArt { - return nil, "", nil - } - return fetchPlaylistImageURL(ctx, parsed) - } - // A missing/unreadable local file is a definitive miss, not a transient - // failure to retry: swallow the open error and fall through to the grid. - r, path, _ := fromLocalFile(imgURL)() - return r, path, nil +// classifyPlaylistImage splits a playlist ExternalImageURL into a local filesystem path +// (served file-backed) or a remote http(s) URL (fetched and stored); at most one is set. +func classifyPlaylistImage(imageURL string) (localPath string, remote *url.URL) { + if imageURL == "" { + return "", nil + } + u, err := url.Parse(imageURL) + if err != nil { + return imageURL, nil // unparseable → treat as a local path + } + switch u.Scheme { + case "http", "https": + return "", u + case "file": + return u.Path, nil + default: + return imageURL, nil } } diff --git a/core/artwork/resolve_test.go b/core/artwork/resolve_test.go index 8309a6cc3..63637240f 100644 --- a/core/artwork/resolve_test.go +++ b/core/artwork/resolve_test.go @@ -432,6 +432,25 @@ var _ = Describe("resolveItem", func() { Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("list.jpg")) }) + It("serves a local ExternalImageURL as a file-backed reference (staleness-checked)", func() { + dir := GinkgoT().TempDir() + imgPath := filepath.Join(dir, "cover.png") + Expect(os.WriteFile(imgPath, []byte("local external image"), 0600)).To(Succeed()) + + plRepo := tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "pll", Name: "Playlist", ExternalImageURL: imgPath}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} + ds.MockedPlaylist = plRepo + + res, err := resolveItem(ctx, ds, ag, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pll"}, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(res.source).To(Equal("folder")) + Expect(res.sourcePath).To(Equal(imgPath)) + Expect(res.refMtime).To(BeNumerically(">", 0)) + }) + It("routes ExternalImageURL through extGate and sets extError on transient failure", func() { conf.Server.EnableM3UExternalAlbumArt = true folderRepo.result = nil // no grid tiles, so the external failure is what surfaces From f016192eec1484860d1a6bea8ab2baee41a0e30d Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 23 Jul 2026 05:30:47 -0400 Subject: [PATCH 75/87] fix(artwork): requeue playlist cover when its track set changes A generated-grid cover went stale after track mutations: nothing re-resolved the playlist's artwork, and the request path deliberately never rebuilds the grid, so serveEntity kept returning the old grid hash indefinitely. Enqueue pl artwork from refreshCounters (the choke point for every track-set change); no clear, so the old cover keeps serving until the worker rebuilds. --- persistence/playlist_repository.go | 7 +++++++ persistence/playlist_repository_test.go | 15 +++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index e984b8852..397838f07 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -324,6 +324,13 @@ func (r *playlistRepository) refreshCounters(pls *model.Playlist) error { pls.SongCount = int(res.Count) pls.Duration = res.Duration pls.Size = int64(res.Size) + // The generated 2x2 grid depends on the track set, so re-resolve the cover whenever it + // changes. No clear: the old cover keeps serving until the worker rebuilds (no flicker). + item := model.ArtworkQueueItem{ItemKind: "pl", ItemID: pls.ID, ImageType: model.ImageTypePrimary, + Priority: model.ArtworkPriorityScan} + if err := NewArtworkQueueRepository(r.ctx, r.db).Enqueue(item); err != nil { + log.Warn(r.ctx, "could not enqueue playlist artwork after content change", "id", pls.ID, err) + } return nil } diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index 8dc86d887..ec5259400 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -260,6 +260,21 @@ var _ = Describe("PlaylistRepository", func() { Expect(repo.Exists(newPls.ID)).To(BeFalse()) }) + It("enqueues the playlist's artwork when its track set changes", func() { + ctx := request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + newPls := model.Playlist{Name: "Grid PL", OwnerID: "userid"} + newPls.AddMediaFilesByID([]string{"1001", "1002"}) + Expect(repo.Put(&newPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(newPls.ID) }) + + queued, err := NewArtworkQueueRepository(ctx, GetDBXBuilder()).DequeueBatch(1000) + Expect(err).ToNot(HaveOccurred()) + Expect(queued).To(ContainElement(SatisfyAll( + HaveField("ItemKind", "pl"), + HaveField("ItemID", newPls.ID), + ))) + }) + Describe("GetAll", func() { It("returns all playlists from DB", func() { all, err := repo.GetAll() From ce06599288f769ad57f11c389b008cace22b11a3 Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 23 Jul 2026 08:01:00 -0400 Subject: [PATCH 76/87] fix(artwork): open library-backed artwork through its on-disk root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A library configured with a file:// path stored absRoot as the raw URI, so Abs produced strings like file:/music/cover.jpg that os.Open/os.Stat reject — folder, upload and embedded art were treated as dangling on every request, looping forever. Normalize a file:// path to its parsed OS path (the same root os.DirFS uses); non-local schemes are left unchanged (out of scope, per the artwork-musicfs TODO). --- core/artwork/library_fs.go | 17 ++++++++++++++++- core/artwork/library_fs_test.go | 7 +++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/core/artwork/library_fs.go b/core/artwork/library_fs.go index ff557294e..fab4b1f8c 100644 --- a/core/artwork/library_fs.go +++ b/core/artwork/library_fs.go @@ -2,7 +2,9 @@ package artwork import ( "context" + "net/url" "path/filepath" + "strings" "github.com/navidrome/navidrome/core/storage" "github.com/navidrome/navidrome/model" @@ -40,5 +42,18 @@ func loadLibraryView(ctx context.Context, ds model.DataStore, libID int) (librar if err != nil { return libraryView{}, err } - return libraryView{FS: fs, absRoot: lib.Path}, nil + return libraryView{FS: fs, absRoot: localOSRoot(lib.Path)}, nil +} + +// localOSRoot maps a library path to its on-disk root so Abs() yields paths os.Open/os.Stat accept: +// a file:// URL becomes its parsed OS path (bare paths already are; non-local schemes stay unchanged). +func localOSRoot(libPath string) string { + if !strings.Contains(libPath, "://") { + return libPath + } + u, err := url.Parse(libPath) + if err != nil || u.Scheme != storage.LocalSchemaID { + return libPath + } + return u.Path } diff --git a/core/artwork/library_fs_test.go b/core/artwork/library_fs_test.go index acf08fda3..22498e7a1 100644 --- a/core/artwork/library_fs_test.go +++ b/core/artwork/library_fs_test.go @@ -32,6 +32,13 @@ var _ = Describe("loadLibraryView", Ordered, func() { Expect(lib.absRoot).To(Equal("fake:///music")) }) + It("normalizes a library path to an OS root that Abs can join for os.Open/os.Stat", func() { + // file:// URLs become their parsed OS path; bare paths and non-local schemes are unchanged. + Expect(localOSRoot("file:///music/library")).To(Equal("/music/library")) + Expect(localOSRoot("/music/library")).To(Equal("/music/library")) + Expect(localOSRoot("fake:///music")).To(Equal("fake:///music")) + }) + It("returns an error when the library does not exist", func() { _, err := loadLibraryView(ctx, ds, 999) Expect(err).To(HaveOccurred()) From ed4178a6a908ac1ffa9a5f34cbe8127def77afb5 Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 23 Jul 2026 08:01:01 -0400 Subject: [PATCH 77/87] fix(artwork): only use disc resolution for multi-disc albums DiscCoverArtID returns a dc- id for any track with DiscNumber>0, so serveDisc ran the full DiscArtPriority chain even for single-disc albums, where a stray disc*/ embedded image could shadow higher-priority album art. Gate disc resolution on the album having more than one disc, matching the legacy reader; single-disc tracks serve album art directly. --- core/artwork/serving.go | 17 +++++++++++------ core/artwork/serving_test.go | 14 +++++++++++++- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/core/artwork/serving.go b/core/artwork/serving.go index 97c46fc9b..4b86644d9 100644 --- a/core/artwork/serving.go +++ b/core/artwork/serving.go @@ -268,12 +268,17 @@ func (s *service) serveDisc(ctx context.Context, artID model.ArtworkID, size int if err != nil { return nil, err } - funcs := dr.fromDiscArtPriority(ctx, s.ffmpeg, conf.Server.DiscArtPriority) - if r, path, err := selectImageReader(ctx, artID, funcs...); err == nil && r != nil { - defer r.Close() - if data, rerr := readCapped(r); rerr == nil { - if hash, herr := HashImage(bytes.NewReader(data)); herr == nil { - return s.serveBytes(ctx, hash, data, unixMtime(mtimeViaFS(dr.lib.FS, path)), size, square) + // Only multi-disc albums use disc-specific resolution (matching the legacy reader); a + // single-disc album serves album art directly, so a stray disc*/embedded image can't + // shadow higher-priority album art. + if len(dr.album.Discs) > 1 { + funcs := dr.fromDiscArtPriority(ctx, s.ffmpeg, conf.Server.DiscArtPriority) + if r, path, err := selectImageReader(ctx, artID, funcs...); err == nil && r != nil { + defer r.Close() + if data, rerr := readCapped(r); rerr == nil { + if hash, herr := HashImage(bytes.NewReader(data)); herr == nil { + return s.serveBytes(ctx, hash, data, unixMtime(mtimeViaFS(dr.lib.FS, path)), size, square) + } } } } diff --git a/core/artwork/serving_test.go b/core/artwork/serving_test.go index b71ae1e05..68ca77cd6 100644 --- a/core/artwork/serving_test.go +++ b/core/artwork/serving_test.go @@ -257,7 +257,7 @@ var _ = Describe("Service", func() { It("delegates a multi-disc track to its disc art, not straight to the album", func() { // Not embedded-eligible: the fallback must mirror CoverArtID (disc first, then album). folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}} - albumRepo.SetData(model.Albums{{ID: "aldd", Name: "Album", FolderIDs: []string{"f1"}}}) + albumRepo.SetData(model.Albums{{ID: "aldd", Name: "Album", FolderIDs: []string{"f1"}, Discs: model.Discs{1: "One", 2: "Two"}}}) seedFoundStore("al", "aldd", []byte("album-art-distinct")) // album's own found art differs mfRepo.SetData(model.MediaFiles{{ID: "mf5", AlbumID: "aldd", DiscNumber: 1, HasCoverArt: false}}) @@ -266,6 +266,18 @@ var _ = Describe("Service", func() { // The disc-folder image wins over the album's found art, proving it routed via serveDisc. Expect(readAll(img)).To(Equal(coverBytes)) }) + + It("delegates a single-disc track straight to the album, skipping disc resolution", func() { + folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}} + albumRepo.SetData(model.Albums{{ID: "alsd", Name: "Album", FolderIDs: []string{"f1"}, Discs: model.Discs{1: ""}}}) + seedFoundStore("al", "alsd", []byte("album-art-distinct")) + mfRepo.SetData(model.MediaFiles{{ID: "mf6", AlbumID: "alsd", DiscNumber: 1, HasCoverArt: false}}) + + img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf6"), 0, false) + Expect(err).ToNot(HaveOccurred()) + // Single-disc album: album art wins; the folder disc image must not shadow it. + Expect(readAll(img)).To(Equal([]byte("album-art-distinct"))) + }) }) Describe("disc", func() { From 50ada9ad29381b45dc94ff5e24f9c8b5c6965605 Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 23 Jul 2026 08:01:01 -0400 Subject: [PATCH 78/87] fix(artwork): invalidate artwork when an uploaded image is deleted Deleting an artist/radio/playlist upload cleared the filename but left the found item_artwork row and its hash, so lists kept advertising the deleted cover's hash-suffixed immutable URL and clients could display it indefinitely. Call EnqueueArtwork after the delete-side Put, symmetric with upload, so the state is cleared and re-resolved to the next source (or absent). --- core/playlists/playlists.go | 6 +++++- core/playlists/playlists_test.go | 18 ++++++++++++++++++ server/nativeapi/artists.go | 6 +++++- server/nativeapi/radios.go | 6 +++++- 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/core/playlists/playlists.go b/core/playlists/playlists.go index a4a26c0f5..57e3e944d 100644 --- a/core/playlists/playlists.go +++ b/core/playlists/playlists.go @@ -339,5 +339,9 @@ func (s *playlists) RemoveImage(ctx context.Context, playlistID string) error { } pls.UploadedImage = "" - return s.ds.Playlist(ctx).Put(pls) + if err := s.ds.Playlist(ctx).Put(pls); err != nil { + return err + } + s.imgUpload.EnqueueArtwork(ctx, consts.EntityPlaylist, pls.ID) + return nil } diff --git a/core/playlists/playlists_test.go b/core/playlists/playlists_test.go index 4120fd2f5..0aeed5cd7 100644 --- a/core/playlists/playlists_test.go +++ b/core/playlists/playlists_test.go @@ -420,6 +420,24 @@ var _ = Describe("Playlists", func() { Expect(mockPlsRepo.Last.UploadedImage).To(BeEmpty()) }) + It("clears the resolved artwork state and re-queues after removing an upload", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + Expect(ds.Artwork(ctx).PutItemArtwork(&model.ItemArtwork{ + ItemKind: "pl", ItemID: "pls-1", Hash: "oldhash", Source: "upload", + })).To(Succeed()) + + Expect(ps.RemoveImage(ctx, "pls-1")).To(Succeed()) + + _, err := ds.Artwork(ctx).GetItemArtwork("pl", "pls-1", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + queued, _ := ds.ArtworkQueue(ctx).DequeueBatch(100) + Expect(queued).To(ContainElement(SatisfyAll( + HaveField("ItemKind", "pl"), + HaveField("ItemID", "pls-1"), + HaveField("Priority", model.ArtworkPriorityBump), + ))) + }) + It("denies non-owner", func() { ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false}) err := ps.RemoveImage(ctx, "pls-1") diff --git a/server/nativeapi/artists.go b/server/nativeapi/artists.go index 90d4c0d27..193f88eda 100644 --- a/server/nativeapi/artists.go +++ b/server/nativeapi/artists.go @@ -69,6 +69,10 @@ func (api *Router) deleteArtistImage() http.HandlerFunc { } ar.UploadedImage = "" ar.UpdatedAt = new(time.Now()) - return api.ds.Artist(ctx).Put(ar, "uploaded_image", "updated_at") + if err := api.ds.Artist(ctx).Put(ar, "uploaded_image", "updated_at"); err != nil { + return err + } + api.imgUpload.EnqueueArtwork(ctx, consts.EntityArtist, ar.ID) + return nil }) } diff --git a/server/nativeapi/radios.go b/server/nativeapi/radios.go index 3868a3501..3e88af287 100644 --- a/server/nativeapi/radios.go +++ b/server/nativeapi/radios.go @@ -69,6 +69,10 @@ func (api *Router) deleteRadioImage() http.HandlerFunc { return err } radio.UploadedImage = "" - return api.ds.Radio(ctx).Put(radio, "UploadedImage") + if err := api.ds.Radio(ctx).Put(radio, "UploadedImage"); err != nil { + return err + } + api.imgUpload.EnqueueArtwork(ctx, consts.EntityRadio, radio.ID) + return nil }) } From 8d715ba2fa583a24eeff9558e243c0b60b1c8017 Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 23 Jul 2026 08:13:03 -0400 Subject: [PATCH 79/87] fix(artwork): restore synthetic-artist guard and unicode normalization in agent lookups Moving agent calls into the worker bypassed two behaviors of the aggregate provider: Agents.GetArtistImages' guard for Unknown/Various Artists (a direct retriever call could assign an unrelated image to a synthetic artist), and auxAlbum/auxArtist.Name's DevPreserveUnicodeInExternalCalls normalization (records with typographic quotes/dashes missed exact-name searches). Re-apply both before enumerating retrievers. --- core/artwork/agent_images.go | 25 ++++++++++++++++-- core/artwork/agent_images_test.go | 42 +++++++++++++++++++++++++------ 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/core/artwork/agent_images.go b/core/artwork/agent_images.go index 95991629f..762c2edb5 100644 --- a/core/artwork/agent_images.go +++ b/core/artwork/agent_images.go @@ -6,10 +6,23 @@ import ( "io" "net/url" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/str" ) +// externalName applies the DevPreserveUnicodeInExternalCalls normalization the aggregate +// provider used, so agent searches match the same way (typographic quotes/dashes cleared +// unless preserved). +func externalName(name string) string { + if conf.Server.DevPreserveUnicodeInExternalCalls { + return name + } + return str.Clear(name) +} + // gateFunc gates one named external fetch (rate limit + circuit breaker per name). // resolveItem defaults to passthroughGate; the worker injects the per-agent gate. type gateFunc = func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) @@ -49,9 +62,16 @@ func bestImageURL(imgs []agents.ExternalImage) *url.URL { // Returns the winning reader + agent name; extErr is true only when NO agent succeeded and // at least one failed transiently (a later success beats an earlier agent error). func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar model.Artist) (r io.ReadCloser, agentName string, extErr bool) { + // Synthetic artists have no real external image; mirror Agents.GetArtistImages' guard so a + // direct retriever call can't assign an unrelated result to Unknown/Various Artists. + switch ar.ID { + case consts.UnknownArtistID, consts.VariousArtistsID: + return nil, "", false + } + name := externalName(ar.Name) for _, a := range ag.ArtistImageAgents() { reader, _, err := gate(a.Name, func() (io.ReadCloser, string, error) { - imgs, err := a.Retriever.GetArtistImages(ctx, ar.ID, ar.Name, ar.MbzArtistID) + imgs, err := a.Retriever.GetArtistImages(ctx, ar.ID, name, ar.MbzArtistID) if err != nil { return nil, "", err } @@ -73,9 +93,10 @@ func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar // fetchAlbumImage is the album counterpart of fetchArtistImage. func fetchAlbumImage(ctx context.Context, ag *agents.Agents, gate gateFunc, al model.Album) (r io.ReadCloser, agentName string, extErr bool) { + name, artist := externalName(al.Name), externalName(al.AlbumArtist) for _, a := range ag.AlbumImageAgents() { reader, _, err := gate(a.Name, func() (io.ReadCloser, string, error) { - imgs, err := a.Retriever.GetAlbumImages(ctx, al.Name, al.AlbumArtist, al.MbzAlbumID) + imgs, err := a.Retriever.GetAlbumImages(ctx, name, artist, al.MbzAlbumID) if err != nil { return nil, "", err } diff --git a/core/artwork/agent_images_test.go b/core/artwork/agent_images_test.go index 0bcd7667a..ea58fe0ac 100644 --- a/core/artwork/agent_images_test.go +++ b/core/artwork/agent_images_test.go @@ -9,9 +9,11 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/str" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -19,22 +21,26 @@ import ( // fakeImageAgent is a built-in agent stub implementing both image retrievers; it // records call counts so per-agent ordering and short-circuiting can be asserted. type fakeImageAgent struct { - name string - imgs []agents.ExternalImage - err error - artistCalls int - albumCalls int + name string + imgs []agents.ExternalImage + err error + artistCalls int + albumCalls int + gotArtistName string + gotAlbumName string } func (f *fakeImageAgent) AgentName() string { return f.name } -func (f *fakeImageAgent) GetArtistImages(context.Context, string, string, string) ([]agents.ExternalImage, error) { +func (f *fakeImageAgent) GetArtistImages(_ context.Context, _, name, _ string) ([]agents.ExternalImage, error) { f.artistCalls++ + f.gotArtistName = name return f.imgs, f.err } -func (f *fakeImageAgent) GetAlbumImages(context.Context, string, string, string) ([]agents.ExternalImage, error) { +func (f *fakeImageAgent) GetAlbumImages(_ context.Context, name, _, _ string) ([]agents.ExternalImage, error) { f.albumCalls++ + f.gotAlbumName = name return f.imgs, f.err } @@ -109,6 +115,28 @@ var _ = Describe("agent images", func() { Expect(extErr).To(BeFalse()) }) + It("skips the external lookup for synthetic artists", func() { + a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}} + ag := imageAgents(a) + + for _, id := range []string{consts.UnknownArtistID, consts.VariousArtistsID} { + r, name, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: id, Name: "Various Artists"}) + Expect(r).To(BeNil()) + Expect(name).To(BeEmpty()) + Expect(extErr).To(BeFalse()) + } + Expect(a.artistCalls).To(Equal(0), "synthetic artists never reach the agents") + }) + + It("clears typographic characters from the query name unless preserving unicode", func() { + conf.Server.DevPreserveUnicodeInExternalCalls = false + a := &fakeImageAgent{name: "agentA"} + ag := imageAgents(a) + + _, _, _ = fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1", Name: "AC’DC"}) + Expect(a.gotArtistName).To(Equal(str.Clear("AC’DC"))) + }) + It("falls through to a later agent, and its success beats the earlier error", func() { a := &fakeImageAgent{name: "agentA", err: errBreakerOpen} b := &fakeImageAgent{name: "agentB", imgs: []agents.ExternalImage{img("/b", 50)}} From 9dd306eb10ef99e1fbcdf1c5db38366d78562ecc Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 23 Jul 2026 08:52:31 -0400 Subject: [PATCH 80/87] fix(artwork): enforce entity visibility on the Subsonic getCoverArt path serveEntity reads persisted item_artwork by id, bypassing the library and private- playlist filters that the legacy entity-load applied. On the authenticated Subsonic path a user could fetch artwork for an inaccessible album or someone else's private playlist by guessing an id. getCoverArt now resolves the underlying entity through the request-scoped (filtered) repositories and serves the placeholder when it is not visible, so existence isn't leaked and the always-an-image invariant holds. The public share (JWT-authorized) and Jellyfin (admin) paths are intentionally untouched. --- core/artwork/serving.go | 11 +++++++ server/subsonic/media_retrieval.go | 40 +++++++++++++++++++++++++ server/subsonic/media_retrieval_test.go | 15 ++++++++++ 3 files changed, 66 insertions(+) diff --git a/core/artwork/serving.go b/core/artwork/serving.go index 4b86644d9..4cdd04883 100644 --- a/core/artwork/serving.go +++ b/core/artwork/serving.go @@ -308,6 +308,10 @@ func (s *service) enqueue(ctx context.Context, artID model.ArtworkID, priority i } func (s *service) placeholder(kind model.Kind) *Image { + return placeholderImage(kind) +} + +func placeholderImage(kind model.Kind) *Image { path := consts.PlaceholderAlbumArt if kind == model.KindArtistArtwork { path = consts.PlaceholderArtistArt @@ -316,6 +320,13 @@ func (s *service) placeholder(kind model.Kind) *Image { return &Image{ReadCloser: r, Placeholder: true} } +// PlaceholderFor returns the kind-appropriate placeholder for an artwork id, for callers that must +// serve a placeholder without consulting persisted state (e.g. an access-control denial). +func PlaceholderFor(id string) *Image { + artID, _ := model.ParseArtworkID(id) + return placeholderImage(artID.Kind) +} + type coverArtIDGetter interface { CoverArtID() model.ArtworkID } diff --git a/server/subsonic/media_retrieval.go b/server/subsonic/media_retrieval.go index 30124c5f8..35e9c1945 100644 --- a/server/subsonic/media_retrieval.go +++ b/server/subsonic/media_retrieval.go @@ -10,6 +10,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/resources" @@ -78,6 +79,14 @@ func (api *Router) GetCoverArt(w http.ResponseWriter, r *http.Request) (*respons log.Error(r, "Error retrieving coverArt", "id", id, err) return nil, err } + + // Access control: the serving path reads persisted state by id, bypassing the library and + // private-playlist filters. On this authenticated path, fall back to the placeholder for an + // entity the caller cannot see, so a guessed id can't leak artwork (matches the legacy load). + if id != "" && !img.Placeholder && !api.artworkAccessible(ctx, id) { + _ = img.Close() + img = artwork.PlaceholderFor(id) + } defer img.Close() artID, _ := model.ParseArtworkID(id) @@ -92,6 +101,37 @@ func (api *Router) GetCoverArt(w http.ResponseWriter, r *http.Request) (*respons return nil, err } +// artworkAccessible reports whether the caller may view the artwork for id, by resolving the +// underlying entity through the request-scoped (filtered) repositories. Radios are global and +// always accessible; an unparsable/unknown id defers to GetEntityByID. +func (api *Router) artworkAccessible(ctx context.Context, id string) bool { + artID, err := model.ParseArtworkID(id) + if err != nil { + _, err := model.GetEntityByID(ctx, api.ds, id) + return err == nil + } + var lookupErr error + switch artID.Kind { + case model.KindArtistArtwork: + _, lookupErr = api.ds.Artist(ctx).Get(artID.ID) + case model.KindAlbumArtwork: + _, lookupErr = api.ds.Album(ctx).Get(artID.ID) + case model.KindMediaFileArtwork: + _, lookupErr = api.ds.MediaFile(ctx).Get(artID.ID) + case model.KindPlaylistArtwork: + _, lookupErr = api.ds.Playlist(ctx).Get(artID.ID) + case model.KindDiscArtwork: + albumID, _, perr := model.ParseDiscArtworkID(artID.ID) + if perr != nil { + return false + } + _, lookupErr = api.ds.Album(ctx).Get(albumID) + default: // radio and anything else has no per-user artwork access control + return true + } + return lookupErr == nil +} + func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) { p := req.Params(r) artist, _ := p.String("artist") diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go index db15d5bfd..1f6cbe84d 100644 --- a/server/subsonic/media_retrieval_test.go +++ b/server/subsonic/media_retrieval_test.go @@ -30,8 +30,11 @@ var _ = Describe("MediaRetrievalController", func() { var w *httptest.ResponseRecorder BeforeEach(func() { + albumRepo := &tests.MockAlbumRepo{} + albumRepo.SetData(model.Albums{{ID: "34"}}) // the id the specs request, made accessible ds = &tests.MockDataStore{ MockedMediaFile: mockRepo, + MockedAlbum: albumRepo, } artwork = &fakeArtwork{data: "image data"} router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(ds, nil), nil, nil) @@ -60,6 +63,18 @@ var _ = Describe("MediaRetrievalController", func() { Expect(w.Body.String()).To(Equal(artwork.data)) }) + It("serves a placeholder for an entity the caller cannot access", func() { + // al-99 is not in the (filtered) album repo, so the caller must not get its bytes. + r := newGetRequest("id=al-99") + _, err := router.GetCoverArt(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(w.Code).To(Equal(200)) + Expect(w.Body.String()).ToNot(Equal(artwork.data)) + Expect(w.Header().Get("Cache-Control")).To(Equal("no-store")) + Expect(w.Header().Get("ETag")).To(BeEmpty()) + }) + It("should fail when the file is not found", func() { artwork.err = model.ErrNotFound r := newGetRequest("id=34", "size=128", "square=true") From 3eaa21229d8de0e05fb076dc73db4f29f4110f81 Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 23 Jul 2026 09:13:04 -0400 Subject: [PATCH 81/87] fix(artwork): version the artwork ETag with the served representation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ETag was the pixel hash of the original image, so a CoverArtQuality or EnableWebPEncoding change altered the resized bytes without changing the ETag — revalidating clients got a spurious 304 and kept the old encoding. Resized responses now carry a representation ETag (hash + size + square + encode settings) used for the ETag header and If-None-Match, while the immutable decision stays on the pixel hash (URLs remain pixel-identity per the spec, so hash-suffixed clients keep zero-request caching). Full-size originals fall back to the pixel hash as before. --- core/artwork/serving.go | 14 +++++++++--- core/artwork/serving_test.go | 4 ++++ server/imghttp/headers.go | 13 +++++++++--- server/imghttp/headers_test.go | 39 ++++++++++++++++++++++++++++++++++ 4 files changed, 64 insertions(+), 6 deletions(-) diff --git a/core/artwork/serving.go b/core/artwork/serving.go index 4cdd04883..9ba5647d7 100644 --- a/core/artwork/serving.go +++ b/core/artwork/serving.go @@ -27,11 +27,19 @@ var errStaleSource = errors.New("artwork: source file changed since resolution") // Image is one servable artwork response. type Image struct { io.ReadCloser - Hash string // "" for placeholders + Hash string // pixel-identity hash (immutable URL match); "" for placeholders + ETag string // served-representation validator; "" falls back to Hash (full-size original) LastUpdated time.Time // zero for placeholders Placeholder bool } +// representationTag identifies a served resized representation for HTTP validation: it changes with +// the dimensions and the encode settings (CoverArtQuality/EnableWebPEncoding), so a config change +// invalidates a revalidating client's cache even though the pixel hash is unchanged. +func representationTag(hash string, size int, square bool) string { + return fmt.Sprintf("%s.%d.%v.%s", hash, size, square, formatQualityTag()) +} + type Service interface { // Get serves resolved/provisional artwork; ErrUnavailable or model.ErrNotFound when // there is nothing to serve (absent, pending, dangling) — caller picks placeholder vs 404. @@ -121,7 +129,7 @@ func (s *service) serveHash(ctx context.Context, artID model.ArtworkID, ia *mode } return s.dangling(ctx, artID) } - return &Image{ReadCloser: stream, Hash: ia.Hash, LastUpdated: ia.UpdatedAt}, nil + return &Image{ReadCloser: stream, Hash: ia.Hash, ETag: representationTag(ia.Hash, size, square), LastUpdated: ia.UpdatedAt}, nil } // openOriginal opens the full-resolution bytes for a found state row, enforcing the @@ -218,7 +226,7 @@ func (s *service) serveBytes(ctx context.Context, hash string, data []byte, last if err != nil { return nil, err } - return &Image{ReadCloser: stream, Hash: hash, LastUpdated: lastUpdate}, nil + return &Image{ReadCloser: stream, Hash: hash, ETag: representationTag(hash, size, square), LastUpdated: lastUpdate}, nil } // serveMediaFile serves a track: own found art wins; an absent row delegates to the album; diff --git a/core/artwork/serving_test.go b/core/artwork/serving_test.go index 68ca77cd6..35e92b522 100644 --- a/core/artwork/serving_test.go +++ b/core/artwork/serving_test.go @@ -106,6 +106,10 @@ var _ = Describe("Service", func() { img, err := svc.Get(ctx, model.MustParseArtworkID("al-al1"), 100, false) Expect(err).ToNot(HaveOccurred()) + // A resized response versions its validator with the encode settings, distinct from + // the pixel hash, so a CoverArtQuality/WebP change invalidates client caches. + Expect(img.ETag).To(Equal(representationTag(img.Hash, 100, false))) + Expect(img.ETag).ToNot(Equal(img.Hash)) resized := readAll(img) cfg, _, err := image.DecodeConfig(bytes.NewReader(resized)) Expect(err).ToNot(HaveOccurred()) diff --git a/server/imghttp/headers.go b/server/imghttp/headers.go index a5f3cf43e..e7bb6d562 100644 --- a/server/imghttp/headers.go +++ b/server/imghttp/headers.go @@ -21,11 +21,18 @@ func WriteImageHeaders(w http.ResponseWriter, r *http.Request, img *artwork.Imag return false } - h.Set("ETag", `"`+img.Hash+`"`) + // The validator identifies the served representation (resized/re-encoded bytes version it via + // ETag), so a CoverArtQuality/EnableWebPEncoding change invalidates a revalidating client's + // cache. Falls back to the pixel hash for full-size originals (bytes == the hash). + etag := img.ETag + if etag == "" { + etag = img.Hash + } + h.Set("ETag", `"`+etag+`"`) if !img.LastUpdated.IsZero() { h.Set("Last-Modified", img.LastUpdated.UTC().Format(http.TimeFormat)) } - // Immutable only when the client asked for the exact current hash; bare/legacy/mismatched + // Immutable only when the client asked for the exact current pixel hash; bare/legacy/mismatched // requests get cheap ETag revalidation instead, which fixes stale art after re-resolution. if requestedHash != "" && requestedHash == img.Hash { h.Set("Cache-Control", "public, max-age=31536000, immutable") @@ -33,7 +40,7 @@ func WriteImageHeaders(w http.ResponseWriter, r *http.Request, img *artwork.Imag h.Set("Cache-Control", "public, no-cache") } - if ifNoneMatch(r.Header.Get("If-None-Match"), img.Hash) { + if ifNoneMatch(r.Header.Get("If-None-Match"), etag) { w.WriteHeader(http.StatusNotModified) return true } diff --git a/server/imghttp/headers_test.go b/server/imghttp/headers_test.go index 6bd824fbe..2d72c3563 100644 --- a/server/imghttp/headers_test.go +++ b/server/imghttp/headers_test.go @@ -28,6 +28,19 @@ func placeholder() *artwork.Image { return &artwork.Image{ReadCloser: io.NopCloser(strings.NewReader("PH")), Placeholder: true} } +const testRepTag = testHash + ".300.false.q75" + +// resized carries a representation ETag distinct from the pixel hash (as a resized/re-encoded +// response does), so the validator versions with the encode settings. +func resized() *artwork.Image { + return &artwork.Image{ + ReadCloser: io.NopCloser(strings.NewReader("IMG")), + Hash: testHash, + ETag: testRepTag, + LastUpdated: lastMod, + } +} + func TestWriteImageHeaders(t *testing.T) { tests := []struct { name string @@ -67,6 +80,32 @@ func TestWriteImageHeaders(t *testing.T) { wantETag: `"` + testHash + `"`, wantLastMod: true, }, + { + name: "resized keeps pixel-hash immutable but serves the representation ETag", + img: resized(), + requestedHash: testHash, + wantCache: "public, max-age=31536000, immutable", + wantETag: `"` + testRepTag + `"`, + wantLastMod: true, + }, + { + name: "resized 304s on the representation ETag, not the pixel hash", + img: resized(), + ifNoneMatch: `"` + testRepTag + `"`, + want304: true, + wantCache: "public, no-cache", + wantETag: `"` + testRepTag + `"`, + wantLastMod: true, + }, + { + name: "resized does not 304 on a stale pixel-hash validator (config changed)", + img: resized(), + ifNoneMatch: `"` + testHash + `"`, + want304: false, + wantCache: "public, no-cache", + wantETag: `"` + testRepTag + `"`, + wantLastMod: true, + }, { name: "If-None-Match matching the hash yields 304", img: found(), From e9c15d7fcb82ead74e9bcc4e8bfe09cf693cf16a Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 23 Jul 2026 09:24:15 -0400 Subject: [PATCH 82/87] fix(artwork): don't stamp the album hash onto multi-disc tracks The hydration fallback assigned a found album hash to every fallback track, but a multi-disc track's CoverArtID emits a dc- id served from disc-specific art whose hash is unknown at hydration time. Advertising dc-..._ gave clients a content- version that never changes when the disc image does, breaking id-based refresh. Only stamp the album hash for single-disc tracks (DiscNumber == 0); multi-disc tracks stay unhashed and rely on the correct ETag returned by the served response. --- persistence/artwork_hydration_test.go | 11 +++++++++++ persistence/mediafile_repository.go | 9 +++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/persistence/artwork_hydration_test.go b/persistence/artwork_hydration_test.go index cb90591d9..d9c80e054 100644 --- a/persistence/artwork_hydration_test.go +++ b/persistence/artwork_hydration_test.go @@ -249,6 +249,17 @@ var _ = Describe("Artwork hydration", func() { Expect(byID["1003"].ImageAbsent).To(BeTrue()) }) + It("does not stamp a found album hash onto a multi-disc track (its dc- id is disc-served)", func() { + putInfo("al", "104", "alh104foundxxxxx") // songs 2002/2004 album is found + + byID := getByID() + + // 2002 is multi-disc (DiscNumber>0); CoverArtID emits a dc- id served from disc art of + // unknown identity, so it must not advertise the album's hash as its content-version. + Expect(byID["2002"].ImageHash).To(BeEmpty()) + Expect(byID["2002"].ImageAbsent).To(BeFalse()) + }) + It("keeps a multi-disc track requestable when its album is absent (disc art may resolve)", func() { putInfo("al", "104", "") // songs 2002/2004 album known-absent diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index 401e485e8..4cc93f4dd 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -247,9 +247,14 @@ func (r *mediaFileRepository) hydrateArtwork(mfs model.MediaFiles) { mf.ImageHash = ownInfo.Hash // own resolved art wins continue } - // Fallback (see MediaFile.CoverArtID): inherit a found album hash for optimistic caching. + // Fallback (see MediaFile.CoverArtID): inherit a found album hash for optimistic caching, + // but only for a single-disc track. A multi-disc track emits a dc- id served from + // disc-specific art of unknown identity, so stamping the album hash would advertise a + // wrong content-version; leave it bare (the served response still carries a correct ETag). if album, ok := albumInfos[mf.AlbumID]; ok && !album.Absent() { - mf.ImageHash = album.Hash + if mf.DiscNumber == 0 { + mf.ImageHash = album.Hash + } continue } // Nothing found. Mark absent only when serving would definitively yield a placeholder: From aa6c0b1f17fdfb28e88d43e8f93266fe947c2175 Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 23 Jul 2026 09:37:07 -0400 Subject: [PATCH 83/87] fix(artwork): enqueue new empty playlists by id, and refresh on absent outcomes Two worker/enqueue fixes from review: - playlistRepository.Put assigned the generated id to the caller's Playlist but passed the stale copy (empty id) to refreshCounters, enqueueing a pl|"" row the worker failed until the daily dangling purge while the real playlist went unresolved. Set the id on the copy before enqueueing. - The drain refresh batch only included found/foundStale, so a cover removed by a scan (found -> absent) never notified clients, leaving the old immutable image displayed. Broadcast absent outcomes too; precache still only warms found/foundStale. --- core/artwork/worker.go | 25 +++++++++++++++---------- core/artwork/worker_test.go | 21 ++++++++++++++++++++- persistence/playlist_repository.go | 1 + persistence/playlist_repository_test.go | 13 +++++++++++++ 4 files changed, 49 insertions(+), 11 deletions(-) diff --git a/core/artwork/worker.go b/core/artwork/worker.go index 01ff548de..5191e2285 100644 --- a/core/artwork/worker.go +++ b/core/artwork/worker.go @@ -135,8 +135,8 @@ func (w *Worker) drain(ctx context.Context, concurrency int) (int, error) { } sem := make(chan struct{}, concurrency) var wg sync.WaitGroup - var foundMu sync.Mutex - var found []model.ArtworkQueueItem + var refreshMu sync.Mutex + var refresh []model.ArtworkQueueItem for _, item := range items { sem <- struct{}{} wg.Add(1) @@ -144,19 +144,24 @@ func (w *Worker) drain(ctx context.Context, concurrency int) (int, error) { defer wg.Done() defer func() { <-sem }() defer w.release(it) - // foundStale also wrote a served state row, so it must refresh the UI too. - if out := w.process(ctx, it); out == outcomeFound || out == outcomeFoundStale { - foundMu.Lock() - found = append(found, it) - foundMu.Unlock() - // Post-outcome only: the queue row was already settled by process, so warming - // the resize cache here can never block or alter queue-op handling. + out := w.process(ctx, it) + // Refresh clients on any visible state change: found/foundStale (new art) and absent + // (removed art — clients must drop a previously-served immutable cover). foundStale + // also wrote a served state row. + if out == outcomeFound || out == outcomeFoundStale || out == outcomeAbsent { + refreshMu.Lock() + refresh = append(refresh, it) + refreshMu.Unlock() + } + // Precache only actual images. Post-outcome only: the queue row was already settled + // by process, so warming the resize cache here can never block or alter queue ops. + if out == outcomeFound || out == outcomeFoundStale { w.precache(ctx, it) } }(item) } wg.Wait() - w.broadcastRefresh(ctx, found) + w.broadcastRefresh(ctx, refresh) return len(items), nil } diff --git a/core/artwork/worker_test.go b/core/artwork/worker_test.go index 3917036c7..d773d6bec 100644 --- a/core/artwork/worker_test.go +++ b/core/artwork/worker_test.go @@ -363,10 +363,29 @@ var _ = Describe("Worker", func() { Expect(data).To(ContainSubstring(`"album"`)) Expect(data).To(ContainSubstring("al1")) Expect(data).To(ContainSubstring("al2")) - Expect(data).ToNot(ContainSubstring("artist"), "the absent artist must not be refreshed") + Expect(data).ToNot(ContainSubstring("artist"), "a failed (unresolved) artist must not be refreshed") Expect(data).ToNot(ContainSubstring("ar1")) }) + It("broadcasts a refresh when an item resolves to absent (removed cover)", func() { + conf.Server.CoverArtPriority = "cover.*" // local-only; no folder image → absent + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al3", Name: "Artless"}}) + folderRepo.result = nil + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3", Priority: model.ArtworkPriorityScan})).To(Succeed()) + + n, err := w.drain(ctx, 2) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(1)) + + evts := broker.getEvents() + Expect(evts).To(HaveLen(1), "a removed cover must live-refresh clients so they drop it") + Expect(evts[0].(*events.RefreshResource).Data(evts[0])).To(ContainSubstring("al3")) + + ia, err := artRepo.GetItemArtwork("al", "al3", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Hash).To(BeEmpty(), "the outcome was absent, not found") + }) + It("does not broadcast when no item is found", func() { conf.Server.CoverArtPriority = "external" ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alx", Name: "Album"}}) diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 397838f07..9942432fa 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -132,6 +132,7 @@ func (r *playlistRepository) Put(p *model.Playlist, cols ...string) error { if len(pls.Tracks) > 0 { return r.updateTracks(id, p.MediaFiles()) } + pls.ID = id // r.put assigns the generated id to p, not to this copy; refreshCounters enqueues by it return r.refreshCounters(&pls.Playlist) } diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index ec5259400..9905d3da6 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -260,6 +260,19 @@ var _ = Describe("PlaylistRepository", func() { Expect(repo.Exists(newPls.ID)).To(BeFalse()) }) + It("enqueues a new empty playlist's artwork under its generated id, not an empty id", func() { + ctx := request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + newPls := model.Playlist{Name: "Empty PL", OwnerID: "userid"} // no tracks → refreshCounters path + Expect(repo.Put(&newPls)).To(Succeed()) + Expect(newPls.ID).ToNot(BeEmpty()) + DeferCleanup(func() { _ = repo.Delete(newPls.ID) }) + + queued, err := NewArtworkQueueRepository(ctx, GetDBXBuilder()).DequeueBatch(1000) + Expect(err).ToNot(HaveOccurred()) + Expect(queued).To(ContainElement(SatisfyAll(HaveField("ItemKind", "pl"), HaveField("ItemID", newPls.ID)))) + Expect(queued).ToNot(ContainElement(HaveField("ItemID", "")), "must not enqueue an empty playlist id") + }) + It("enqueues the playlist's artwork when its track set changes", func() { ctx := request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: "userid", UserName: "userid", IsAdmin: true}) newPls := model.Playlist{Name: "Grid PL", OwnerID: "userid"} From aba7ed925c37f48b62f95a81656a7bee88ff705c Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 23 Jul 2026 09:59:27 -0400 Subject: [PATCH 84/87] fix(artwork): honor disabled per-track art at serve time; use nanosecond mtime provenance Two serving-correctness fixes from review: - serveMediaFile served a persisted mf embedded image even after EnableMediaFileCoverArt was turned off (the setting isn't in the config fingerprint, so found rows aren't reprocessed). Direct mf- URLs now honor the setting at serve time and fall back to disc/album art. - The file-backed staleness check compared whole-second mtimes, so a same-second content replacement (two writes in one second, or timestamp-preserving tools) could serve different bytes under the old hash + immutable policy. RefMtime is now unix-nanoseconds (no schema change; int64 column), detecting sub-second changes where the filesystem records them. --- core/artwork/processor_test.go | 6 +++--- core/artwork/resolve.go | 6 +++--- core/artwork/serving.go | 16 +++++++++++++--- core/artwork/serving_test.go | 14 +++++++++++++- model/artwork.go | 2 +- 5 files changed, 33 insertions(+), 11 deletions(-) diff --git a/core/artwork/processor_test.go b/core/artwork/processor_test.go index bdb208562..3d145ff71 100644 --- a/core/artwork/processor_test.go +++ b/core/artwork/processor_test.go @@ -257,7 +257,7 @@ var _ = Describe("processItem", func() { Expect(err).ToNot(HaveOccurred()) Expect(iaA.Source).To(Equal("folder")) Expect(filepath.ToSlash(iaA.SourcePath)).To(HaveSuffix("album-a/cover.jpg")) - Expect(iaA.RefMtime).To(Equal(int64(1000))) + Expect(iaA.RefMtime).To(Equal(time.Unix(1000, 0).UnixNano())) // Poison the shared row's blurhash: the second item must dedup on hash, not re-decode. poisoned := artRepo.Data[iaA.Hash] @@ -270,13 +270,13 @@ var _ = Describe("processItem", func() { Expect(err).ToNot(HaveOccurred()) Expect(iaB.Hash).To(Equal(iaA.Hash)) Expect(filepath.ToSlash(iaB.SourcePath)).To(HaveSuffix("album-b/cover.jpg")) - Expect(iaB.RefMtime).To(Equal(int64(2000))) + Expect(iaB.RefMtime).To(Equal(time.Unix(2000, 0).UnixNano())) // The first item's provenance survives the second item processing identical bytes. iaAafter, err := artRepo.GetItemArtwork("al", "alA", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(filepath.ToSlash(iaAafter.SourcePath)).To(HaveSuffix("album-a/cover.jpg")) - Expect(iaAafter.RefMtime).To(Equal(int64(1000))) + Expect(iaAafter.RefMtime).To(Equal(time.Unix(1000, 0).UnixNano())) // One shared artwork row, and dedup preserved it untouched. Expect(artRepo.Data).To(HaveLen(1)) diff --git a/core/artwork/resolve.go b/core/artwork/resolve.go index 862d0bee7..ff095bf24 100644 --- a/core/artwork/resolve.go +++ b/core/artwork/resolve.go @@ -29,7 +29,7 @@ type resolution struct { reader io.ReadCloser // nil when no source yielded an image source string // model.ItemArtwork.Source value: "folder", "embedded", "external", "upload", "generated" sourcePath string // backing library/upload file (folder/upload: the image; embedded: the audio file); "" otherwise - refMtime int64 // mtime of sourcePath at resolution time; 0 when no sourcePath + refMtime int64 // sourcePath mtime (unix-nanoseconds) at resolution; 0 when no sourcePath // external source errored/timed out. With no reader: forces failed (never absent). // On a hit: a higher-priority external step failed—serve this, but retry later. extError bool @@ -410,7 +410,7 @@ func mtimeOf(path string) int64 { if err != nil { return 0 } - return info.ModTime().Unix() + return info.ModTime().UnixNano() } // mtimeViaFS stats through the library FS instead of a joined absolute path, @@ -423,7 +423,7 @@ func mtimeViaFS(fsys fs.FS, name string) int64 { if err != nil { return 0 } - return info.ModTime().Unix() + return info.ModTime().UnixNano() } // decodeTile and assembleTiles mirror playlistArtworkReader's createTile/ diff --git a/core/artwork/serving.go b/core/artwork/serving.go index 9ba5647d7..4a1b9737a 100644 --- a/core/artwork/serving.go +++ b/core/artwork/serving.go @@ -145,7 +145,7 @@ func openOriginal(ia *model.ItemArtwork, mime string, store *ImageStore) (io.Rea f.Close() return nil, err } - if ia.RefMtime != 0 && info.ModTime().Unix() != ia.RefMtime { + if ia.RefMtime != 0 && info.ModTime().UnixNano() != ia.RefMtime { f.Close() return nil, errStaleSource } @@ -158,7 +158,7 @@ func openOriginal(ia *model.ItemArtwork, mime string, store *ImageStore) (io.Rea if err != nil { return nil, err } - if info.ModTime().Unix() != ia.RefMtime { + if info.ModTime().UnixNano() != ia.RefMtime { return nil, errStaleSource } } @@ -232,6 +232,16 @@ func (s *service) serveBytes(ctx context.Context, hash string, data []byte, last // serveMediaFile serves a track: own found art wins; an absent row delegates to the album; // a missing row extracts embedded art (if eligible, enqueuing) else delegates without enqueue. func (s *service) serveMediaFile(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) { + // Per-track art can be disabled after mf rows were resolved (the setting is not in the + // config fingerprint). Honor it at serve time so a direct mf- URL falls back to disc/album + // instead of serving stale persisted embedded art. + if !conf.Server.EnableMediaFileCoverArt { + mf, err := s.ds.MediaFile(ctx).Get(artID.ID) + if err != nil { + return nil, err + } + return s.Get(ctx, mf.DiscCoverArtID(), size, square) + } ia, err := s.ds.Artwork(ctx).GetItemArtwork("mf", artID.ID, model.ImageTypePrimary) switch { case err == nil && ia.Hash != "": @@ -362,7 +372,7 @@ func unixMtime(mtime int64) time.Time { if mtime <= 0 { return time.Time{} } - return time.Unix(mtime, 0) + return time.Unix(0, mtime) // RefMtime is unix-nanoseconds } // resizedItem is an artworkReader that resizes bytes opened by open() and caches the diff --git a/core/artwork/serving_test.go b/core/artwork/serving_test.go index 35e92b522..9dc4db90d 100644 --- a/core/artwork/serving_test.go +++ b/core/artwork/serving_test.go @@ -219,6 +219,18 @@ var _ = Describe("Service", func() { Expect(readAll(img)).To(Equal(coverBytes)) }) + It("ignores a resolved mf row and delegates to the album when per-track art is disabled", func() { + conf.Server.EnableMediaFileCoverArt = false + // A resolved mf row exists (from when the setting was on) but must not be served. + seedFoundStore("mf", "mf7", []byte("stale embedded track art")) + seedFoundStore("al", "albz", coverBytes) + mfRepo.SetData(model.MediaFiles{{ID: "mf7", AlbumID: "albz"}}) + + img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf7"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(readAll(img)).To(Equal(coverBytes), "album art, not the persisted embedded art") + }) + It("delegates to the album when the track's state is absent", func() { seedFoundStore("al", "albm", coverBytes) Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "mf", ItemID: "mf2"})).To(Succeed()) @@ -348,5 +360,5 @@ func fileMtime(path string) int64 { GinkgoHelper() info, err := os.Stat(path) Expect(err).ToNot(HaveOccurred()) - return info.ModTime().Unix() + return info.ModTime().UnixNano() } diff --git a/model/artwork.go b/model/artwork.go index 6d343946e..a21936535 100644 --- a/model/artwork.go +++ b/model/artwork.go @@ -31,7 +31,7 @@ type ItemArtwork struct { Source string `structs:"source"` // SourcePath is the backing file (folder/upload: the image; embedded: the audio file); "" otherwise. SourcePath string `structs:"source_path"` - // RefMtime is SourcePath's mtime at resolution; 0 when there is no SourcePath. + // RefMtime is SourcePath's mtime (unix-nanoseconds) at resolution; 0 when there is no SourcePath. RefMtime int64 `structs:"ref_mtime"` // attempted_at/updated_at are nullable in the schema but always set by PutItemArtwork; // raw inserts must set them too, since these non-pointer time.Time fields fail to scan NULL. From f92507b15215a713aaebac79934427d19413d078 Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 23 Jul 2026 10:40:31 -0400 Subject: [PATCH 85/87] fix(artwork): preserve the drive when normalizing Windows file:// library paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit url.Parse puts the volume of file://C:/Music in Host, not Path, so localOSRoot dropped it and returned /Music — os.Open/os.Stat then failed and folder/embedded art on Windows looped as dangling. Rejoin the host volume, matching core/storage/local's newLocalStorage. --- core/artwork/library_fs.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/core/artwork/library_fs.go b/core/artwork/library_fs.go index fab4b1f8c..6e6099325 100644 --- a/core/artwork/library_fs.go +++ b/core/artwork/library_fs.go @@ -55,5 +55,10 @@ func localOSRoot(libPath string) string { if err != nil || u.Scheme != storage.LocalSchemaID { return libPath } + // Windows drive URLs (file://C:/Music) put the volume in Host; rejoin it, matching + // core/storage/local's newLocalStorage so os.Open/os.Stat get a valid path. + if filepath.VolumeName(u.Host) != "" { + return filepath.Join(u.Host, u.Path) + } return u.Path } From 42fd263cdffdbb861b734e11d11ad3fd7de72845 Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 23 Jul 2026 11:25:10 -0400 Subject: [PATCH 86/87] fix(artwork): clamp negative sizes to full-size; convert imghttp test to Ginkgo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A negative size (Subsonic size / Jellyfin maxwidth accept signed ints) reached resizeStaticImage, where the square path builds image.NewNRGBA(Rect(0,0,size,size)) — a giant rectangle that panics/OOMs. Clamp size<0 to 0 (full-size) at the Service entry. Positive sizes were already clamped to the original. - imghttp used a plain func Test with a table; convert to a Ginkgo DescribeTable with the suite entry point in imghttp_suite_test.go (AGENTS.md test-framework requirement). --- core/artwork/serving.go | 3 + core/artwork/serving_test.go | 8 ++ server/imghttp/headers_test.go | 196 +++++++-------------------- server/imghttp/imghttp_suite_test.go | 17 +++ 4 files changed, 76 insertions(+), 148 deletions(-) create mode 100644 server/imghttp/imghttp_suite_test.go diff --git a/core/artwork/serving.go b/core/artwork/serving.go index 4a1b9737a..f4e67cd19 100644 --- a/core/artwork/serving.go +++ b/core/artwork/serving.go @@ -76,6 +76,9 @@ func (s *service) Get(ctx context.Context, artID model.ArtworkID, size int, squa if artID.ID == "" { return nil, ErrUnavailable } + if size < 0 { + size = 0 // a negative size is a full-size request, not a giant (OOM) resize rectangle + } switch artID.Kind { case model.KindDiscArtwork: return s.serveDisc(ctx, artID, size, square) diff --git a/core/artwork/serving_test.go b/core/artwork/serving_test.go index 9dc4db90d..0f344e4a0 100644 --- a/core/artwork/serving_test.go +++ b/core/artwork/serving_test.go @@ -126,6 +126,14 @@ var _ = Describe("Service", func() { }).Should(Succeed()) }) + It("treats a negative size as a full-size request, not a giant resize", func() { + seedFoundStore("al", "alneg", coverBytes) + + img, err := svc.Get(ctx, model.MustParseArtworkID("al-alneg"), -2000000000, false) + Expect(err).ToNot(HaveOccurred()) + Expect(readAll(img)).To(Equal(coverBytes), "original bytes, no resize (would OOM)") + }) + It("streams a file-backed found image at full size", func() { dir := GinkgoT().TempDir() imgPath := filepath.Join(dir, "cover.jpg") diff --git a/server/imghttp/headers_test.go b/server/imghttp/headers_test.go index 2d72c3563..52a549556 100644 --- a/server/imghttp/headers_test.go +++ b/server/imghttp/headers_test.go @@ -5,14 +5,16 @@ import ( "net/http" "net/http/httptest" "strings" - "testing" "time" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/server/imghttp" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" ) const testHash = "0123456789abcdef" +const testRepTag = testHash + ".300.false.q75" var lastMod = time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC) @@ -28,8 +30,6 @@ func placeholder() *artwork.Image { return &artwork.Image{ReadCloser: io.NopCloser(strings.NewReader("PH")), Placeholder: true} } -const testRepTag = testHash + ".300.false.q75" - // resized carries a representation ETag distinct from the pixel hash (as a resized/re-encoded // response does), so the validator versions with the encode settings. func resized() *artwork.Image { @@ -41,9 +41,8 @@ func resized() *artwork.Image { } } -func TestWriteImageHeaders(t *testing.T) { - tests := []struct { - name string +var _ = Describe("WriteImageHeaders", func() { + type testCase struct { img *artwork.Image requestedHash string ifNoneMatch string @@ -51,155 +50,56 @@ func TestWriteImageHeaders(t *testing.T) { wantCache string wantETag string wantLastMod bool - }{ - { - name: "placeholder is never cached and carries no validators", - img: placeholder(), - wantCache: "no-store", - }, - { - name: "found with matching requested hash is immutable", - img: found(), - requestedHash: testHash, - wantCache: "public, max-age=31536000, immutable", - wantETag: `"` + testHash + `"`, - wantLastMod: true, - }, - { - name: "found with bare id revalidates via no-cache", - img: found(), - wantCache: "public, no-cache", - wantETag: `"` + testHash + `"`, - wantLastMod: true, - }, - { - name: "found with mismatched requested hash revalidates", - img: found(), - requestedHash: "ffffffffffffffff", - wantCache: "public, no-cache", - wantETag: `"` + testHash + `"`, - wantLastMod: true, - }, - { - name: "resized keeps pixel-hash immutable but serves the representation ETag", - img: resized(), - requestedHash: testHash, - wantCache: "public, max-age=31536000, immutable", - wantETag: `"` + testRepTag + `"`, - wantLastMod: true, - }, - { - name: "resized 304s on the representation ETag, not the pixel hash", - img: resized(), - ifNoneMatch: `"` + testRepTag + `"`, - want304: true, - wantCache: "public, no-cache", - wantETag: `"` + testRepTag + `"`, - wantLastMod: true, - }, - { - name: "resized does not 304 on a stale pixel-hash validator (config changed)", - img: resized(), - ifNoneMatch: `"` + testHash + `"`, - want304: false, - wantCache: "public, no-cache", - wantETag: `"` + testRepTag + `"`, - wantLastMod: true, - }, - { - name: "If-None-Match matching the hash yields 304", - img: found(), - requestedHash: testHash, - ifNoneMatch: `"` + testHash + `"`, - want304: true, - wantCache: "public, max-age=31536000, immutable", - wantETag: `"` + testHash + `"`, - wantLastMod: true, - }, - { - name: "weak If-None-Match matches (weak comparison)", - img: found(), - ifNoneMatch: `W/"` + testHash + `"`, - want304: true, - wantCache: "public, no-cache", - wantETag: `"` + testHash + `"`, - wantLastMod: true, - }, - { - name: "If-None-Match with multiple values matches one", - img: found(), - ifNoneMatch: `"deadbeefdeadbeef", W/"` + testHash + `", "cafecafecafecafe"`, - want304: true, - wantCache: "public, no-cache", - wantETag: `"` + testHash + `"`, - wantLastMod: true, - }, - { - name: "If-None-Match star matches any current representation", - img: found(), - ifNoneMatch: "*", - want304: true, - wantCache: "public, no-cache", - wantETag: `"` + testHash + `"`, - wantLastMod: true, - }, - { - name: "non-matching If-None-Match serves body", - img: found(), - ifNoneMatch: `"deadbeefdeadbeef"`, - want304: false, - wantCache: "public, no-cache", - wantETag: `"` + testHash + `"`, - wantLastMod: true, - }, - { - name: "placeholder ignores If-None-Match and never 304s", - img: placeholder(), - ifNoneMatch: "*", - want304: false, - wantCache: "no-store", - }, } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { + DescribeTable("applies the artwork caching contract", + func(c testCase) { w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/img", nil) - if tc.ifNoneMatch != "" { - r.Header.Set("If-None-Match", tc.ifNoneMatch) + if c.ifNoneMatch != "" { + r.Header.Set("If-None-Match", c.ifNoneMatch) } - got := imghttp.WriteImageHeaders(w, r, tc.img, tc.requestedHash) - if got != tc.want304 { - t.Fatalf("wrote304 = %v, want %v", got, tc.want304) - } + Expect(imghttp.WriteImageHeaders(w, r, c.img, c.requestedHash)).To(Equal(c.want304)) h := w.Header() - if got := h.Get("Cache-Control"); got != tc.wantCache { - t.Errorf("Cache-Control = %q, want %q", got, tc.wantCache) + Expect(h.Get("Cache-Control")).To(Equal(c.wantCache)) + Expect(h.Get("ETag")).To(Equal(c.wantETag)) + if c.img.Placeholder { + Expect(h.Get("ETag")).To(BeEmpty(), "placeholder must not set an ETag") + Expect(h.Get("Last-Modified")).To(BeEmpty(), "placeholder must not set Last-Modified") } - if got := h.Get("ETag"); got != tc.wantETag { - t.Errorf("ETag = %q, want %q", got, tc.wantETag) + Expect(h.Get("Last-Modified") != "").To(Equal(c.wantLastMod)) + if c.want304 { + Expect(w.Code).To(Equal(http.StatusNotModified)) + Expect(w.Body.Len()).To(BeZero(), "a 304 must have an empty body") } - if tc.img.Placeholder { - if h.Get("ETag") != "" { - t.Errorf("placeholder must not set ETag, got %q", h.Get("ETag")) - } - if h.Get("Last-Modified") != "" { - t.Errorf("placeholder must not set Last-Modified, got %q", h.Get("Last-Modified")) - } - } - if hasLM := h.Get("Last-Modified") != ""; hasLM != tc.wantLastMod { - t.Errorf("has Last-Modified = %v, want %v", hasLM, tc.wantLastMod) - } - if tc.want304 { - if w.Code != http.StatusNotModified { - t.Errorf("status = %d, want 304", w.Code) - } - if w.Body.Len() != 0 { - t.Errorf("304 must have empty body, got %q", w.Body.String()) - } - } - }) - } -} + }, + Entry("placeholder is never cached and carries no validators", + testCase{img: placeholder(), wantCache: "no-store"}), + Entry("found with matching requested hash is immutable", + testCase{img: found(), requestedHash: testHash, wantCache: "public, max-age=31536000, immutable", wantETag: `"` + testHash + `"`, wantLastMod: true}), + Entry("found with bare id revalidates via no-cache", + testCase{img: found(), wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}), + Entry("found with mismatched requested hash revalidates", + testCase{img: found(), requestedHash: "ffffffffffffffff", wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}), + Entry("resized keeps pixel-hash immutable but serves the representation ETag", + testCase{img: resized(), requestedHash: testHash, wantCache: "public, max-age=31536000, immutable", wantETag: `"` + testRepTag + `"`, wantLastMod: true}), + Entry("resized 304s on the representation ETag, not the pixel hash", + testCase{img: resized(), ifNoneMatch: `"` + testRepTag + `"`, want304: true, wantCache: "public, no-cache", wantETag: `"` + testRepTag + `"`, wantLastMod: true}), + Entry("resized does not 304 on a stale pixel-hash validator (config changed)", + testCase{img: resized(), ifNoneMatch: `"` + testHash + `"`, want304: false, wantCache: "public, no-cache", wantETag: `"` + testRepTag + `"`, wantLastMod: true}), + Entry("If-None-Match matching the hash yields 304", + testCase{img: found(), requestedHash: testHash, ifNoneMatch: `"` + testHash + `"`, want304: true, wantCache: "public, max-age=31536000, immutable", wantETag: `"` + testHash + `"`, wantLastMod: true}), + Entry("weak If-None-Match matches (weak comparison)", + testCase{img: found(), ifNoneMatch: `W/"` + testHash + `"`, want304: true, wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}), + Entry("If-None-Match with multiple values matches one", + testCase{img: found(), ifNoneMatch: `"deadbeefdeadbeef", W/"` + testHash + `", "cafecafecafecafe"`, want304: true, wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}), + Entry("If-None-Match star matches any current representation", + testCase{img: found(), ifNoneMatch: "*", want304: true, wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}), + Entry("non-matching If-None-Match serves body", + testCase{img: found(), ifNoneMatch: `"deadbeefdeadbeef"`, want304: false, wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}), + Entry("placeholder ignores If-None-Match and never 304s", + testCase{img: placeholder(), ifNoneMatch: "*", want304: false, wantCache: "no-store"}), + ) +}) diff --git a/server/imghttp/imghttp_suite_test.go b/server/imghttp/imghttp_suite_test.go new file mode 100644 index 000000000..7a747db5b --- /dev/null +++ b/server/imghttp/imghttp_suite_test.go @@ -0,0 +1,17 @@ +package imghttp_test + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestImgHTTP(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "ImgHTTP Suite") +} From a998a329e6f6d8399dddbac6268b725538953fb9 Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 23 Jul 2026 14:09:39 -0400 Subject: [PATCH 87/87] test(artwork): use renamed ArtworkWorkerConcurrency in e2e tests --- core/artwork/e2e/acquire_serve_test.go | 2 +- server/subsonic/e2e/subsonic_artwork_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/core/artwork/e2e/acquire_serve_test.go b/core/artwork/e2e/acquire_serve_test.go index 5059e5fe8..3a20a0245 100644 --- a/core/artwork/e2e/acquire_serve_test.go +++ b/core/artwork/e2e/acquire_serve_test.go @@ -67,7 +67,7 @@ var _ = Describe("Acquisition → serve loop", func() { conf.Server.CoverArtPriority = "cover.jpg" conf.Server.ArtistArtPriority = "artist.png" // upload wins first; kept offline as a safety net conf.Server.EnableMediaFileCoverArt = true - conf.Server.DevArtworkWorkerConcurrency = 1 + conf.Server.ArtworkWorkerConcurrency = 1 folderRepo = &fakeFolderRepo{} libRepo = &tests.MockLibraryRepo{} diff --git a/server/subsonic/e2e/subsonic_artwork_test.go b/server/subsonic/e2e/subsonic_artwork_test.go index fc4bec8a0..6c92f271b 100644 --- a/server/subsonic/e2e/subsonic_artwork_test.go +++ b/server/subsonic/e2e/subsonic_artwork_test.go @@ -94,7 +94,7 @@ var _ = Describe("Artwork Serving", Ordered, func() { conf.Server.CoverArtPriority = "cover.jpg" conf.Server.ArtistArtPriority = "artist.png" // offline: artists resolve absent, out of scope here conf.Server.EnableMediaFileCoverArt = false - conf.Server.DevArtworkWorkerConcurrency = 1 + conf.Server.ArtworkWorkerConcurrency = 1 conf.Server.CacheFolder = conf.NewDir(GinkgoT().TempDir()) conf.Server.EnableSharing = true conf.Server.DevArtworkMaxRequests = 100