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