From 4652b4660205abf38a484a2dc7856b4f6d5c24aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 8 Jul 2026 12:37:42 -0400 Subject: [PATCH] fix(plugins): populate username for buffered plugin scrobbles (#5736) 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. --- core/scrobbler/buffered_scrobbler.go | 9 +++++++++ core/scrobbler/buffered_scrobbler_test.go | 13 +++++++++++++ core/scrobbler/play_tracker_test.go | 17 +++++++++++++++++ 3 files changed, 39 insertions(+) diff --git a/core/scrobbler/buffered_scrobbler.go b/core/scrobbler/buffered_scrobbler.go index 67593e9eb..408ab410d 100644 --- a/core/scrobbler/buffered_scrobbler.go +++ b/core/scrobbler/buffered_scrobbler.go @@ -7,6 +7,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" ) // 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 { + // 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) for { entry, err := buffer.Next(b.service, userId) diff --git a/core/scrobbler/buffered_scrobbler_test.go b/core/scrobbler/buffered_scrobbler_test.go index 9fbca6f71..d11f0b003 100644 --- a/core/scrobbler/buffered_scrobbler_test.go +++ b/core/scrobbler/buffered_scrobbler_test.go @@ -20,8 +20,11 @@ var _ = Describe("BufferedScrobbler", func() { BeforeEach(func() { ctx = context.Background() buffer = tests.CreateMockedScrobbleBufferRepo() + userRepo := tests.CreateMockUserRepo() + Expect(userRepo.Put(&model.User{ID: "user1", UserName: "alice"})).To(Succeed()) ds = &tests.MockDataStore{ MockedScrobbleBuffer: buffer, + MockedUser: userRepo, } scr = &fakeScrobbler{Authorized: true} bs = newBufferedScrobbler(ds, scr, "test") @@ -62,6 +65,16 @@ var _ = Describe("BufferedScrobbler", func() { 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() { // Replace the real run method with one that signals when it exits done := make(chan struct{}) diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index 382abb811..74b4be893 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -1098,6 +1098,13 @@ func (f *fakeScrobbler) GetUserID() string { return "" } +func (f *fakeScrobbler) GetUsername() string { + if p := f.username.Load(); p != nil { + return *p + } + return "" +} + func (f *fakeScrobbler) GetTrack() *model.MediaFile { 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 { 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.ScrobbleCalled.Store(true) if f.Error != nil {