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