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.
This commit is contained in:
Deluan
2025-12-09 08:43:56 -05:00
parent cc3cca6077
commit 396eee48c6
2 changed files with 35 additions and 3 deletions
+6 -3
View File
@@ -32,6 +32,7 @@ type Submission struct {
} }
type nowPlayingEntry struct { type nowPlayingEntry struct {
ctx context.Context
userId string userId string
track *model.MediaFile track *model.MediaFile
position int position int
@@ -220,15 +221,17 @@ func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerNam
} }
player, _ := request.PlayerFrom(ctx) player, _ := request.PlayerFrom(ctx)
if player.ScrobbleEnabled { if player.ScrobbleEnabled {
p.enqueueNowPlaying(playerId, user.ID, mf, position) p.enqueueNowPlaying(ctx, playerId, user.ID, mf, position)
} }
return nil 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() p.npMu.Lock()
defer p.npMu.Unlock() defer p.npMu.Unlock()
ctx = context.WithoutCancel(ctx) // Prevent cancellation from affecting background processing
p.npQueue[playerId] = nowPlayingEntry{ p.npQueue[playerId] = nowPlayingEntry{
ctx: ctx,
userId: userId, userId: userId,
track: track, track: track,
position: position, position: position,
@@ -267,7 +270,7 @@ func (p *playTracker) nowPlayingWorker() {
// Process entries without holding lock // Process entries without holding lock
for _, entry := range entries { for _, entry := range entries {
p.dispatchNowPlaying(context.Background(), entry.userId, entry.track, entry.position) p.dispatchNowPlaying(entry.ctx, entry.userId, entry.track, entry.position)
} }
} }
} }
+29
View File
@@ -170,6 +170,17 @@ var _ = Describe("PlayTracker", func() {
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(eventBroker.getEvents()).To(BeEmpty()) 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() { Describe("GetNowPlaying", func() {
@@ -428,6 +439,7 @@ type fakeScrobbler struct {
nowPlayingCalled atomic.Bool nowPlayingCalled atomic.Bool
ScrobbleCalled atomic.Bool ScrobbleCalled atomic.Bool
userID atomic.Pointer[string] userID atomic.Pointer[string]
username atomic.Pointer[string]
track atomic.Pointer[model.MediaFile] track atomic.Pointer[model.MediaFile]
position atomic.Int32 position atomic.Int32
LastScrobble atomic.Pointer[Scrobble] LastScrobble atomic.Pointer[Scrobble]
@@ -453,6 +465,13 @@ func (f *fakeScrobbler) GetPosition() int {
return int(f.position.Load()) 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 { func (f *fakeScrobbler) IsAuthorized(ctx context.Context, userId string) bool {
return f.Error == nil && f.Authorized return f.Error == nil && f.Authorized
} }
@@ -463,6 +482,16 @@ func (f *fakeScrobbler) NowPlaying(ctx context.Context, userId string, track *mo
return f.Error return f.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.track.Store(track) f.track.Store(track)
f.position.Store(int32(position)) f.position.Store(int32(position))
return nil return nil