fix(plugins): populate username for buffered plugin scrobbles (#5736)
Some checks are pending
Pipeline: Test, Lint, Build / Get version info (push) Waiting to run
Pipeline: Test, Lint, Build / Lint Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Test Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Test Go code (Windows) (push) Waiting to run
Pipeline: Test, Lint, Build / Test JS code (push) Waiting to run
Pipeline: Test, Lint, Build / Lint i18n files (push) Waiting to run
Pipeline: Test, Lint, Build / Check Docker configuration (push) Waiting to run
Pipeline: Test, Lint, Build / Build (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-1 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-2 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-3 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-4 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-5 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-6 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-7 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-8 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-9 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-10 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Push to GHCR (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build Windows installers (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Package/Release (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Blocked by required conditions

Plugin scrobblers read the username from the request context via
getUsernameFromContext, but buffered scrobbles are persisted to the DB
scrobble buffer (which stores only the userId) and later drained by a
background worker running on context.Background(). That context carries
no authenticated user, so ScrobbleRequest.Username was always empty for
WASM scrobbler plugins. NowPlaying is unaffected because it is dispatched
synchronously on context.WithoutCancel(requestCtx), which retains the user.

Restore the user in the drain path: processUserQueue now looks up the
user by the buffered userId and injects it into the context via
request.WithUser before dispatching, mirroring the pattern already used
in play_tracker. Builtin scrobblers are unaffected as they resolve the
account from the userId argument rather than the context.
This commit is contained in:
Deluan Quintão 2026-07-08 12:37:42 -04:00 committed by GitHub
parent f48943c058
commit 4652b46602
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 39 additions and 0 deletions

View file

@ -7,6 +7,7 @@ import (
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
) )
// Loader is a function that loads a scrobbler by name. // Loader is a function that loads a scrobbler by name.
@ -129,6 +130,14 @@ func (b *bufferedScrobbler) processQueue(ctx context.Context) bool {
} }
func (b *bufferedScrobbler) processUserQueue(ctx context.Context, userId string) bool { func (b *bufferedScrobbler) processUserQueue(ctx context.Context, userId string) bool {
// Scrobbles are drained on a background context that no longer carries the
// request's authenticated user. Restore it from the buffered userId so that
// scrobblers relying on the user in the context (e.g. plugins) still get it.
if user, err := b.ds.User(ctx).Get(userId); err != nil {
log.Warn(ctx, "Could not load user for buffered scrobble", "userId", userId, "scrobbler", b.service, err)
} else {
ctx = request.WithUser(ctx, *user)
}
buffer := b.ds.ScrobbleBuffer(ctx) buffer := b.ds.ScrobbleBuffer(ctx)
for { for {
entry, err := buffer.Next(b.service, userId) entry, err := buffer.Next(b.service, userId)

View file

@ -20,8 +20,11 @@ var _ = Describe("BufferedScrobbler", func() {
BeforeEach(func() { BeforeEach(func() {
ctx = context.Background() ctx = context.Background()
buffer = tests.CreateMockedScrobbleBufferRepo() buffer = tests.CreateMockedScrobbleBufferRepo()
userRepo := tests.CreateMockUserRepo()
Expect(userRepo.Put(&model.User{ID: "user1", UserName: "alice"})).To(Succeed())
ds = &tests.MockDataStore{ ds = &tests.MockDataStore{
MockedScrobbleBuffer: buffer, MockedScrobbleBuffer: buffer,
MockedUser: userRepo,
} }
scr = &fakeScrobbler{Authorized: true} scr = &fakeScrobbler{Authorized: true}
bs = newBufferedScrobbler(ds, scr, "test") bs = newBufferedScrobbler(ds, scr, "test")
@ -62,6 +65,16 @@ var _ = Describe("BufferedScrobbler", func() {
Expect(lastScrobble.TimeStamp).To(BeTemporally("==", now)) Expect(lastScrobble.TimeStamp).To(BeTemporally("==", now))
}) })
It("restores the user in the context when draining buffered scrobbles", func() {
track := model.MediaFile{ID: "123", Title: "Test Track", Artist: "Test Artist"}
scrobble := Scrobble{MediaFile: track, TimeStamp: time.Now()}
Expect(bs.Scrobble(ctx, "user1", scrobble)).To(Succeed())
Eventually(scr.ScrobbleCalled.Load).Should(BeTrue())
Expect(scr.GetUsername()).To(Equal("alice"))
})
It("stops the background goroutine when Stop is called", func() { It("stops the background goroutine when Stop is called", func() {
// Replace the real run method with one that signals when it exits // Replace the real run method with one that signals when it exits
done := make(chan struct{}) done := make(chan struct{})

View file

@ -1098,6 +1098,13 @@ func (f *fakeScrobbler) GetUserID() string {
return "" return ""
} }
func (f *fakeScrobbler) GetUsername() string {
if p := f.username.Load(); p != nil {
return *p
}
return ""
}
func (f *fakeScrobbler) GetTrack() *model.MediaFile { func (f *fakeScrobbler) GetTrack() *model.MediaFile {
return f.track.Load() return f.track.Load()
} }
@ -1129,6 +1136,16 @@ func (f *fakeScrobbler) NowPlaying(ctx context.Context, userId string, track *mo
func (f *fakeScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error { func (f *fakeScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error {
f.userID.Store(&userId) f.userID.Store(&userId)
// Capture username from context (this is what plugin scrobblers do)
username, _ := request.UsernameFrom(ctx)
if username == "" {
if u, ok := request.UserFrom(ctx); ok {
username = u.UserName
}
}
if username != "" {
f.username.Store(&username)
}
f.LastScrobble.Store(&s) f.LastScrobble.Store(&s)
f.ScrobbleCalled.Store(true) f.ScrobbleCalled.Store(true)
if f.Error != nil { if f.Error != nil {