From 396eee48c625709d712ac22f11851b03501eb2af Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 9 Dec 2025 08:43:56 -0500 Subject: [PATCH] fix: preserve user context in async NowPlaying dispatch Fixed issue #4787 where plugin scrobblers received an empty username during NowPlaying events. The async worker was passing context.Background() which lost all user information. Changed nowPlayingEntry to store the full context (with cancellation removed via context.WithoutCancel) and pass it to dispatchNowPlaying. This ensures plugin scrobblers can extract username from the context for authorization checks. Updated tests to verify username is properly propagated through the async workflow, matching the actual plugin adapter behavior of checking both request.UsernameFrom and request.UserFrom. --- core/scrobbler/play_tracker.go | 9 ++++++--- core/scrobbler/play_tracker_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go index bac9d220b..49c1dd87b 100644 --- a/core/scrobbler/play_tracker.go +++ b/core/scrobbler/play_tracker.go @@ -32,6 +32,7 @@ type Submission struct { } type nowPlayingEntry struct { + ctx context.Context userId string track *model.MediaFile position int @@ -220,15 +221,17 @@ func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerNam } player, _ := request.PlayerFrom(ctx) if player.ScrobbleEnabled { - p.enqueueNowPlaying(playerId, user.ID, mf, position) + p.enqueueNowPlaying(ctx, playerId, user.ID, mf, position) } return nil } -func (p *playTracker) enqueueNowPlaying(playerId string, userId string, track *model.MediaFile, position int) { +func (p *playTracker) enqueueNowPlaying(ctx context.Context, playerId string, userId string, track *model.MediaFile, position int) { p.npMu.Lock() defer p.npMu.Unlock() + ctx = context.WithoutCancel(ctx) // Prevent cancellation from affecting background processing p.npQueue[playerId] = nowPlayingEntry{ + ctx: ctx, userId: userId, track: track, position: position, @@ -267,7 +270,7 @@ func (p *playTracker) nowPlayingWorker() { // Process entries without holding lock for _, entry := range entries { - p.dispatchNowPlaying(context.Background(), entry.userId, entry.track, entry.position) + p.dispatchNowPlaying(entry.ctx, entry.userId, entry.track, entry.position) } } } diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index 6f66276c3..839590e6b 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -170,6 +170,17 @@ var _ = Describe("PlayTracker", func() { Expect(err).ToNot(HaveOccurred()) Expect(eventBroker.getEvents()).To(BeEmpty()) }) + + It("passes user to scrobbler via context (fix for issue #4787)", func() { + ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "testuser"}) + ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true}) + + err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) + Expect(err).ToNot(HaveOccurred()) + Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue()) + // Verify the username was passed through async dispatch via context + Eventually(func() string { return fake.GetUsername() }).Should(Equal("testuser")) + }) }) Describe("GetNowPlaying", func() { @@ -428,6 +439,7 @@ type fakeScrobbler struct { nowPlayingCalled atomic.Bool ScrobbleCalled atomic.Bool userID atomic.Pointer[string] + username atomic.Pointer[string] track atomic.Pointer[model.MediaFile] position atomic.Int32 LastScrobble atomic.Pointer[Scrobble] @@ -453,6 +465,13 @@ func (f *fakeScrobbler) GetPosition() int { return int(f.position.Load()) } +func (f *fakeScrobbler) GetUsername() string { + if p := f.username.Load(); p != nil { + return *p + } + return "" +} + func (f *fakeScrobbler) IsAuthorized(ctx context.Context, userId string) bool { return f.Error == nil && f.Authorized } @@ -463,6 +482,16 @@ func (f *fakeScrobbler) NowPlaying(ctx context.Context, userId string, track *mo return f.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.track.Store(track) f.position.Store(int32(position)) return nil