fix(artwork): resolve private playlists with an admin context

This commit is contained in:
Deluan 2026-07-22 14:03:42 -04:00
parent 7713a6d6b2
commit 55608b2d20
4 changed files with 74 additions and 0 deletions

View File

@ -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, "")

View File

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

View File

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

View File

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