package scrobbler import ( "context" "errors" "net/http" "sync" "sync/atomic" "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) type mockPluginLoader struct { mu sync.RWMutex names []string scrobblers map[string]Scrobbler } func (m *mockPluginLoader) PluginNames(service string) []string { m.mu.RLock() defer m.mu.RUnlock() return m.names } func (m *mockPluginLoader) SetNames(names []string) { m.mu.Lock() defer m.mu.Unlock() m.names = names } func (m *mockPluginLoader) LoadScrobbler(name string) (Scrobbler, bool) { m.mu.RLock() defer m.mu.RUnlock() s, ok := m.scrobblers[name] return s, ok } var _ = Describe("PlayTracker", func() { var ctx context.Context var ds model.DataStore var tracker *playTracker var eventBroker *fakeEventBroker var track model.MediaFile var album model.Album var artist1 model.Artist var artist2 model.Artist var fake *fakeScrobbler BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) ctx = GinkgoT().Context() ctx = request.WithUser(ctx, model.User{ID: "u-1"}) ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true}) ds = &tests.MockDataStore{} fake = &fakeScrobbler{Authorized: true} Register("fake", func(model.DataStore) Scrobbler { return fake }) Register("disabled", func(model.DataStore) Scrobbler { return nil }) eventBroker = &fakeEventBroker{} tracker = newPlayTracker(ds, eventBroker, nil) tracker.builtinScrobblers["fake"] = fake // Bypass buffering for tests track = model.MediaFile{ ID: "123", Title: "Track Title", Album: "Track Album", AlbumID: "al-1", TrackNumber: 1, Duration: 180, MbzRecordingID: "mbz-123", Participants: map[model.Role]model.ParticipantList{ model.RoleArtist: []model.Participant{_p("ar-1", "Artist 1"), _p("ar-2", "Artist 2")}, }, } _ = ds.MediaFile(ctx).Put(&track) artist1 = model.Artist{ID: "ar-1"} _ = ds.Artist(ctx).Put(&artist1) artist2 = model.Artist{ID: "ar-2"} _ = ds.Artist(ctx).Put(&artist2) album = model.Album{ID: "al-1"} _ = ds.Album(ctx).(*tests.MockAlbumRepo).Put(&album) }) AfterEach(func() { // Stop the worker goroutine to prevent data races between tests tracker.stopBackgroundWorkers() }) It("does not register disabled scrobblers", func() { Expect(tracker.builtinScrobblers).To(HaveKey("fake")) Expect(tracker.builtinScrobblers).ToNot(HaveKey("disabled")) }) Describe("GetNowPlaying", func() { It("returns current playing music", func() { track2 := track track2.ID = "456" _ = ds.MediaFile(ctx).Put(&track2) ctx1 := request.WithUser(GinkgoT().Context(), model.User{UserName: "user-1"}) ctx1 = request.WithPlayer(ctx1, model.Player{ScrobbleEnabled: true}) _ = tracker.ReportPlayback(ctx1, ReportPlaybackParams{ MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1", ClientName: "player-one", }) ctx2 := request.WithUser(GinkgoT().Context(), model.User{UserName: "user-2"}) ctx2 = request.WithPlayer(ctx2, model.Player{ScrobbleEnabled: true}) _ = tracker.ReportPlayback(ctx2, ReportPlaybackParams{ MediaId: "456", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-2", ClientName: "player-two", }) playing, err := tracker.GetNowPlaying(ctx) Expect(err).ToNot(HaveOccurred()) Expect(playing).To(HaveLen(2)) Expect(playing[0].PlayerId).To(Equal("player-2")) Expect(playing[0].PlayerName).To(Equal("player-two")) Expect(playing[0].Username).To(Equal("user-2")) Expect(playing[0].MediaFile.ID).To(Equal("456")) Expect(playing[1].PlayerId).To(Equal("player-1")) Expect(playing[1].PlayerName).To(Equal("player-one")) Expect(playing[1].Username).To(Equal("user-1")) Expect(playing[1].MediaFile.ID).To(Equal("123")) }) }) Describe("Expiration events", func() { It("sends event when entry expires", func() { info := PlaybackSession{MediaFile: track, Start: time.Now(), Username: "user"} _ = tracker.playMap.AddWithTTL("player-1", info, 10*time.Millisecond) Eventually(func() int { return len(eventBroker.getEvents()) }).Should(BeNumerically(">", 0)) eventList := eventBroker.getEvents() evt, ok := eventList[len(eventList)-1].(*events.NowPlayingCount) Expect(ok).To(BeTrue()) Expect(evt.Count).To(Equal(0)) }) It("does not send event when disabled", func() { conf.Server.EnableNowPlaying = false tracker = newPlayTracker(ds, eventBroker, nil) info := PlaybackSession{MediaFile: track, Start: time.Now(), Username: "user"} _ = tracker.playMap.AddWithTTL("player-2", info, 10*time.Millisecond) Consistently(func() int { return len(eventBroker.getEvents()) }).Should(Equal(0)) }) It("sends expired playback report when session expires", func() { info := PlaybackSession{ MediaFile: track, Start: time.Now(), UserId: "u-1", Username: "user", PlayerId: "player-3", PlayerName: "test-player", State: StatePlaying, PositionMs: 5000, } _ = tracker.playMap.AddWithTTL("player-3", info, 10*time.Millisecond) Eventually(func() *PlaybackSession { return fake.LastPlaybackReport.Load() }).ShouldNot(BeNil()) report := fake.LastPlaybackReport.Load() Expect(report.State).To(Equal(StateExpired)) Expect(report.MediaFile.ID).To(Equal("123")) Expect(report.PlayerId).To(Equal("player-3")) }) It("does not send expired report when session was already stopped", func() { info := PlaybackSession{ MediaFile: track, Start: time.Now(), UserId: "u-1", Username: "user", PlayerId: "player-4", PlayerName: "test-player", State: StateStopped, PositionMs: 180000, } _ = tracker.playMap.AddWithTTL("player-4", info, 10*time.Millisecond) Consistently(func() *PlaybackSession { return fake.LastPlaybackReport.Load() }).Should(BeNil()) }) }) Describe("Submit", func() { It("sends track to agent", func() { ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1"}) ts := time.Now() err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: ts}}) Expect(err).ToNot(HaveOccurred()) Expect(fake.ScrobbleCalled.Load()).To(BeTrue()) Expect(fake.GetUserID()).To(Equal("u-1")) lastScrobble := fake.LastScrobble.Load() Expect(lastScrobble.TimeStamp).To(BeTemporally("~", ts, 1*time.Second)) Expect(lastScrobble.ID).To(Equal("123")) Expect(lastScrobble.Participants).To(Equal(track.Participants)) }) It("increments play counts in the DB", func() { ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1"}) ts := time.Now() err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: ts}}) Expect(err).ToNot(HaveOccurred()) Expect(track.PlayCount).To(Equal(int64(1))) Expect(album.PlayCount).To(Equal(int64(1))) // It should increment play counts for all artists Expect(artist1.PlayCount).To(Equal(int64(1))) Expect(artist2.PlayCount).To(Equal(int64(1))) }) It("does not send track to agent if user has not authorized", func() { fake.Authorized = false err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) Expect(err).ToNot(HaveOccurred()) Expect(fake.ScrobbleCalled.Load()).To(BeFalse()) }) It("does not send track to agent if player is not enabled to send scrobbles", func() { ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: false}) err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) Expect(err).ToNot(HaveOccurred()) Expect(fake.ScrobbleCalled.Load()).To(BeFalse()) }) It("does not send track to agent if artist is unknown", func() { track.Artist = consts.UnknownArtist err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) Expect(err).ToNot(HaveOccurred()) Expect(fake.ScrobbleCalled.Load()).To(BeFalse()) }) It("increments play counts even if it cannot scrobble", func() { fake.Error = errors.New("error") err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) Expect(err).ToNot(HaveOccurred()) Expect(fake.ScrobbleCalled.Load()).To(BeFalse()) Expect(track.PlayCount).To(Equal(int64(1))) Expect(album.PlayCount).To(Equal(int64(1))) // It should increment play counts for all artists Expect(artist1.PlayCount).To(Equal(int64(1))) Expect(artist2.PlayCount).To(Equal(int64(1))) }) Context("Scrobble History", func() { It("records scrobble in repository", func() { conf.Server.EnableScrobbleHistory = true ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1"}) ts := time.Now() err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: ts}}) Expect(err).ToNot(HaveOccurred()) mockDS := ds.(*tests.MockDataStore) mockScrobble := mockDS.Scrobble(ctx).(*tests.MockScrobbleRepo) Expect(mockScrobble.RecordedScrobbles).To(HaveLen(1)) Expect(mockScrobble.RecordedScrobbles[0].MediaFileID).To(Equal("123")) Expect(mockScrobble.RecordedScrobbles[0].UserID).To(Equal("u-1")) Expect(mockScrobble.RecordedScrobbles[0].SubmissionTime).To(Equal(ts)) }) It("does not record scrobble when history is disabled", func() { conf.Server.EnableScrobbleHistory = false ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1"}) ts := time.Now() err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: ts}}) Expect(err).ToNot(HaveOccurred()) mockDS := ds.(*tests.MockDataStore) mockScrobble := mockDS.Scrobble(ctx).(*tests.MockScrobbleRepo) Expect(mockScrobble.RecordedScrobbles).To(HaveLen(0)) }) }) }) Describe("ReportPlayback", func() { const defaultClientId = "client-1" BeforeEach(func() { ctx = request.WithPlayer(ctx, model.Player{ID: "p1", ScrobbleEnabled: true}) }) It("creates entry on starting and removes on stopped", func() { err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) playing, err := tracker.GetNowPlaying(ctx) Expect(err).ToNot(HaveOccurred()) Expect(playing).To(HaveLen(1)) Expect(playing[0].State).To(Equal("starting")) Expect(playing[0].MediaFile.ID).To(Equal("123")) err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 0, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, IgnoreScrobble: true, }) Expect(err).ToNot(HaveOccurred()) playing, err = tracker.GetNowPlaying(ctx) Expect(err).ToNot(HaveOccurred()) Expect(playing).To(BeEmpty()) }) It("full lifecycle: starting -> playing -> paused -> playing -> stopped", func() { err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) playing, err := tracker.GetNowPlaying(ctx) Expect(err).ToNot(HaveOccurred()) Expect(playing).To(HaveLen(1)) Expect(playing[0].State).To(Equal("playing")) Expect(playing[0].PositionMs).To(BeNumerically(">=", int64(10000))) err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 30000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) playing, err = tracker.GetNowPlaying(ctx) Expect(err).ToNot(HaveOccurred()) Expect(playing[0].State).To(Equal("paused")) Expect(playing[0].PositionMs).To(Equal(int64(30000))) err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 30000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 100000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) playing, err = tracker.GetNowPlaying(ctx) Expect(err).ToNot(HaveOccurred()) Expect(playing).To(BeEmpty()) }) It("starting replaces existing entry for same player", func() { err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 50000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) playing, err := tracker.GetNowPlaying(ctx) Expect(err).ToNot(HaveOccurred()) Expect(playing).To(HaveLen(1)) Expect(playing[0].State).To(Equal("starting")) Expect(playing[0].PositionMs).To(Equal(int64(0))) }) It("multiple players have independent sessions", func() { ctx1 := request.WithUser(ctx, model.User{ID: "u-1", UserName: "user1"}) ctx1 = request.WithPlayer(ctx1, model.Player{ID: "p1", ScrobbleEnabled: true}) ctx2 := request.WithUser(ctx, model.User{ID: "u-1", UserName: "user1"}) ctx2 = request.WithPlayer(ctx2, model.Player{ID: "p2", ScrobbleEnabled: true}) track2 := track track2.ID = "456" _ = ds.MediaFile(ctx).Put(&track2) err := tracker.ReportPlayback(ctx1, ReportPlaybackParams{ MediaId: "123", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: "client-1", }) Expect(err).ToNot(HaveOccurred()) err = tracker.ReportPlayback(ctx2, ReportPlaybackParams{ MediaId: "456", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: "client-2", }) Expect(err).ToNot(HaveOccurred()) playing, err := tracker.GetNowPlaying(ctx) Expect(err).ToNot(HaveOccurred()) Expect(playing).To(HaveLen(2)) }) Describe("SSE broadcast on state change", func() { BeforeEach(func() { eventBroker = &fakeEventBroker{} tracker = newPlayTracker(ds, eventBroker, nil) tracker.builtinScrobblers["fake"] = fake }) It("broadcasts NowPlayingCount on every state change", func() { // starting -> count should be 1 err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) evts := eventBroker.getEvents() Expect(evts).To(HaveLen(1)) Expect(evts[0].(*events.NowPlayingCount).Count).To(Equal(1)) // playing -> count should be 1 err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) evts = eventBroker.getEvents() Expect(evts).To(HaveLen(2)) Expect(evts[1].(*events.NowPlayingCount).Count).To(Equal(1)) // paused -> count should be 1 err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 30000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) evts = eventBroker.getEvents() Expect(evts).To(HaveLen(3)) Expect(evts[2].(*events.NowPlayingCount).Count).To(Equal(1)) // stopped -> count should be 0 err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 30000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, IgnoreScrobble: true, }) Expect(err).ToNot(HaveOccurred()) evts = eventBroker.getEvents() Expect(evts).To(HaveLen(4)) Expect(evts[3].(*events.NowPlayingCount).Count).To(Equal(0)) }) It("does NOT broadcast when EnableNowPlaying is false", func() { conf.Server.EnableNowPlaying = false tracker = newPlayTracker(ds, eventBroker, nil) tracker.builtinScrobblers["fake"] = fake err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) Expect(eventBroker.getEvents()).To(BeEmpty()) }) }) Describe("auto-scrobble", func() { It("scrobbles on stopped when positionMs >= 50% of track", func() { err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) Expect(track.PlayCount).To(Equal(int64(1))) Expect(album.PlayCount).To(Equal(int64(1))) Expect(artist1.PlayCount).To(Equal(int64(1))) }) It("scrobbles on stopped when positionMs >= 4 min for long tracks", func() { longTrack := model.MediaFile{ ID: "long", Title: "Long Song", Album: "Album", AlbumID: "al-1", Duration: 600, Participants: map[model.Role]model.ParticipantList{ model.RoleArtist: []model.Participant{_p("ar-1", "Artist 1")}, }, } _ = ds.MediaFile(ctx).Put(&longTrack) err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "long", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "long", PositionMs: 240000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) Expect(longTrack.PlayCount).To(Equal(int64(1))) }) It("does NOT scrobble when positionMs below threshold", func() { err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 10000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) Expect(track.PlayCount).To(Equal(int64(0))) }) It("does NOT scrobble when ignoreScrobble=true even if threshold met", func() { err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, IgnoreScrobble: true, }) Expect(err).ToNot(HaveOccurred()) Expect(track.PlayCount).To(Equal(int64(0))) }) It("does NOT scrobble when player ScrobbleEnabled=false even if threshold met", func() { ctx = request.WithPlayer(ctx, model.Player{ID: "p1", ScrobbleEnabled: false}) err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) Expect(track.PlayCount).To(Equal(int64(0))) }) It("scrobbles twice for two separate sessions of same song", func() { err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) Expect(track.PlayCount).To(Equal(int64(2))) }) It("dispatches to external scrobblers on auto-scrobble", func() { fake.ScrobbleCalled.Store(false) err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) Expect(fake.ScrobbleCalled.Load()).To(BeTrue()) }) }) Describe("position estimation", func() { It("estimates position for playing state based on elapsed time", func() { err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) time.Sleep(50 * time.Millisecond) playing, err := tracker.GetNowPlaying(ctx) Expect(err).ToNot(HaveOccurred()) Expect(playing).To(HaveLen(1)) Expect(playing[0].PositionMs).To(BeNumerically(">", int64(10000))) }) It("does NOT estimate for paused", func() { err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 10000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) time.Sleep(50 * time.Millisecond) playing, err := tracker.GetNowPlaying(ctx) Expect(err).ToNot(HaveOccurred()) Expect(playing).To(HaveLen(1)) Expect(playing[0].PositionMs).To(Equal(int64(10000))) }) It("does NOT estimate for starting", func() { err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) time.Sleep(50 * time.Millisecond) playing, err := tracker.GetNowPlaying(ctx) Expect(err).ToNot(HaveOccurred()) Expect(playing).To(HaveLen(1)) Expect(playing[0].PositionMs).To(Equal(int64(0))) }) It("respects playbackRate", func() { err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 2.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) time.Sleep(100 * time.Millisecond) playing, err := tracker.GetNowPlaying(ctx) Expect(err).ToNot(HaveOccurred()) Expect(playing).To(HaveLen(1)) // At 2x speed, 100ms real time = ~200ms playback time Expect(playing[0].PositionMs).To(BeNumerically(">", int64(10100))) }) It("caps estimated position at track duration", func() { err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 179990, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) time.Sleep(50 * time.Millisecond) playing, err := tracker.GetNowPlaying(ctx) Expect(err).ToNot(HaveOccurred()) Expect(playing).To(HaveLen(1)) Expect(playing[0].PositionMs).To(Equal(int64(180000))) // track.Duration * 1000 }) }) Describe("resilience (no prior starting)", func() { It("playing without prior starting creates entry with Start approx now - positionMs", func() { err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 30000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) playing, err := tracker.GetNowPlaying(ctx) Expect(err).ToNot(HaveOccurred()) Expect(playing).To(HaveLen(1)) Expect(playing[0].State).To(Equal("playing")) expectedStart := time.Now().Add(-30 * time.Second) Expect(playing[0].Start).To(BeTemporally("~", expectedStart, 2*time.Second)) }) It("paused without prior starting creates entry", func() { err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 30000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) playing, err := tracker.GetNowPlaying(ctx) Expect(err).ToNot(HaveOccurred()) Expect(playing).To(HaveLen(1)) Expect(playing[0].State).To(Equal("paused")) }) It("stopped without prior starting auto-scrobbles if threshold met", func() { err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) Expect(track.PlayCount).To(Equal(int64(1))) }) It("stopped without prior starting does NOT scrobble if below threshold", func() { err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 10000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) Expect(track.PlayCount).To(Equal(int64(0))) }) }) Describe("external scrobbler dispatch", func() { It("dispatches NowPlaying on starting", func() { fake.nowPlayingCalled.Store(false) err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue()) }) It("dispatches NowPlaying on playing", func() { fake.nowPlayingCalled.Store(false) err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue()) }) It("does NOT dispatch on paused", func() { fake.nowPlayingCalled.Store(false) err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 10000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse()) }) It("does NOT dispatch when ignoreScrobble=true", func() { fake.nowPlayingCalled.Store(false) err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, IgnoreScrobble: true, }) Expect(err).ToNot(HaveOccurred()) Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse()) }) It("does NOT dispatch when ScrobbleEnabled=false", func() { fake.nowPlayingCalled.Store(false) ctx = request.WithPlayer(ctx, model.Player{ID: "p1", ScrobbleEnabled: false}) err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse()) }) }) Describe("PlaybackReport dispatch", func() { It("dispatches PlaybackReport for starting state", func() { err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 0, State: StateStarting, PlaybackRate: 1.0, ClientId: "client-1", ClientName: "Test Player", }) Expect(err).ToNot(HaveOccurred()) Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue()) info := fake.LastPlaybackReport.Load() Expect(info).ToNot(BeNil()) Expect(info.MediaFile.ID).To(Equal("123")) Expect(info.State).To(Equal(StateStarting)) Expect(info.PositionMs).To(Equal(int64(0))) Expect(info.PlaybackRate).To(Equal(1.0)) Expect(info.PlayerId).To(Equal("client-1")) Expect(info.PlayerName).To(Equal("Test Player")) }) It("dispatches PlaybackReport for playing state", func() { err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 0, State: StateStarting, PlaybackRate: 1.0, ClientId: "client-1", ClientName: "Test Player", }) Expect(err).ToNot(HaveOccurred()) Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue()) fake.PlaybackReportCalled.Store(false) fake.LastPlaybackReport.Store(nil) err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 30000, State: StatePlaying, PlaybackRate: 1.5, ClientId: "client-1", ClientName: "Test Player", }) Expect(err).ToNot(HaveOccurred()) Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue()) info := fake.LastPlaybackReport.Load() Expect(info.State).To(Equal(StatePlaying)) Expect(info.PositionMs).To(Equal(int64(30000))) Expect(info.PlaybackRate).To(Equal(1.5)) }) It("dispatches PlaybackReport for paused state", func() { err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 0, State: StateStarting, PlaybackRate: 1.0, ClientId: "client-1", ClientName: "Test Player", }) Expect(err).ToNot(HaveOccurred()) Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue()) fake.PlaybackReportCalled.Store(false) fake.LastPlaybackReport.Store(nil) err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 45000, State: StatePaused, PlaybackRate: 1.0, ClientId: "client-1", ClientName: "Test Player", }) Expect(err).ToNot(HaveOccurred()) Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue()) info := fake.LastPlaybackReport.Load() Expect(info.State).To(Equal(StatePaused)) Expect(info.PositionMs).To(Equal(int64(45000))) }) It("dispatches PlaybackReport for stopped state", func() { err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 0, State: StateStarting, PlaybackRate: 1.0, ClientId: "client-1", ClientName: "Test Player", }) Expect(err).ToNot(HaveOccurred()) Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue()) fake.PlaybackReportCalled.Store(false) fake.LastPlaybackReport.Store(nil) err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 100000, State: StateStopped, PlaybackRate: 1.0, ClientId: "client-1", ClientName: "Test Player", }) Expect(err).ToNot(HaveOccurred()) Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue()) info := fake.LastPlaybackReport.Load() Expect(info.State).To(Equal(StateStopped)) Expect(info.PositionMs).To(Equal(int64(100000))) }) }) }) Describe("Plugin scrobbler logic", func() { var pluginLoader *mockPluginLoader var pluginFake *fakeScrobbler BeforeEach(func() { pluginFake = &fakeScrobbler{Authorized: true} pluginLoader = &mockPluginLoader{ names: []string{"plugin1"}, scrobblers: map[string]Scrobbler{"plugin1": pluginFake}, } tracker = newPlayTracker(ds, events.GetBroker(), pluginLoader) // Bypass buffering for both built-in and plugin scrobblers tracker.builtinScrobblers["fake"] = fake tracker.pluginScrobblers["plugin1"] = pluginFake }) It("registers and uses plugin scrobbler for NowPlaying", func() { err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1", }) Expect(err).ToNot(HaveOccurred()) Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue()) }) It("removes plugin scrobbler if not present anymore", func() { _ = tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1", }) Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue()) pluginFake.nowPlayingCalled.Store(false) pluginLoader.SetNames([]string{}) _ = tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1", }) Consistently(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeFalse()) }) It("calls both builtin and plugin scrobblers for NowPlaying", func() { fake.nowPlayingCalled.Store(false) pluginFake.nowPlayingCalled.Store(false) err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1", }) Expect(err).ToNot(HaveOccurred()) Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue()) Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue()) }) It("calls plugin scrobbler for Submit", func() { ts := time.Now() err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: ts}}) Expect(err).ToNot(HaveOccurred()) Expect(pluginFake.ScrobbleCalled.Load()).To(BeTrue()) }) }) Describe("Plugin Scrobbler Management", func() { var pluginScr *fakeScrobbler var mockPlugin *mockPluginLoader var pTracker *playTracker var mockedBS *mockBufferedScrobbler BeforeEach(func() { ctx = GinkgoT().Context() ctx = request.WithUser(ctx, model.User{ID: "u-1"}) ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true}) ds = &tests.MockDataStore{} // Setup plugin scrobbler pluginScr = &fakeScrobbler{Authorized: true} mockPlugin = &mockPluginLoader{ names: []string{"plugin1"}, scrobblers: map[string]Scrobbler{"plugin1": pluginScr}, } // Create a tracker with the mock plugin loader pTracker = newPlayTracker(ds, events.GetBroker(), mockPlugin) // Create a mock buffered scrobbler and explicitly cast it to Scrobbler mockedBS = &mockBufferedScrobbler{ wrapped: pluginScr, } // Make sure the instance is added with its concrete type preserved pTracker.pluginScrobblers["plugin1"] = mockedBS }) It("calls Stop on scrobblers when removing them", func() { // Change the plugin names to simulate a plugin being removed mockPlugin.SetNames([]string{}) // Call refreshPluginScrobblers which should detect the removed plugin pTracker.refreshPluginScrobblers() // Verify the Stop method was called Expect(mockedBS.stopCalled).To(BeTrue()) // Verify the scrobbler was removed from the map Expect(pTracker.pluginScrobblers).NotTo(HaveKey("plugin1")) }) }) Describe("Plugin reload (config update) behavior", func() { var mockPlugin *mockPluginLoader var pTracker *playTracker var originalScrobbler *fakeScrobbler var reloadedScrobbler *fakeScrobbler BeforeEach(func() { ctx = GinkgoT().Context() ctx = request.WithUser(ctx, model.User{ID: "u-1"}) ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true}) ds = &tests.MockDataStore{} // Setup initial plugin scrobbler originalScrobbler = &fakeScrobbler{Authorized: true} reloadedScrobbler = &fakeScrobbler{Authorized: true} mockPlugin = &mockPluginLoader{ names: []string{"plugin1"}, scrobblers: map[string]Scrobbler{"plugin1": originalScrobbler}, } // Create tracker - this will create buffered scrobblers with loaders pTracker = newPlayTracker(ds, events.GetBroker(), mockPlugin) // Trigger initial plugin registration pTracker.refreshPluginScrobblers() }) AfterEach(func() { pTracker.stopBackgroundWorkers() }) It("uses the new plugin instance after reload (simulating config update)", func() { // First call should use the original scrobbler scrobblers := pTracker.getActiveScrobblers() pluginScr := scrobblers["plugin1"] Expect(pluginScr).ToNot(BeNil()) err := pluginScr.NowPlaying(ctx, "u-1", &track, 0) Expect(err).ToNot(HaveOccurred()) Expect(originalScrobbler.GetNowPlayingCalled()).To(BeTrue()) Expect(reloadedScrobbler.GetNowPlayingCalled()).To(BeFalse()) // Simulate plugin reload (config update): replace the scrobbler in the loader // This is what happens when UpdatePluginConfig is called - the plugin manager // unloads the old plugin and loads a new instance mockPlugin.mu.Lock() mockPlugin.scrobblers["plugin1"] = reloadedScrobbler mockPlugin.mu.Unlock() // Reset call tracking originalScrobbler.nowPlayingCalled.Store(false) // Get scrobblers again - should still return the same buffered scrobbler // but subsequent calls should use the new plugin instance via the loader scrobblers = pTracker.getActiveScrobblers() pluginScr = scrobblers["plugin1"] err = pluginScr.NowPlaying(ctx, "u-1", &track, 0) Expect(err).ToNot(HaveOccurred()) // The new scrobbler should be called, not the old one Expect(reloadedScrobbler.GetNowPlayingCalled()).To(BeTrue()) Expect(originalScrobbler.GetNowPlayingCalled()).To(BeFalse()) }) It("handles plugin becoming unavailable temporarily", func() { // First verify plugin works scrobblers := pTracker.getActiveScrobblers() pluginScr := scrobblers["plugin1"] err := pluginScr.NowPlaying(ctx, "u-1", &track, 0) Expect(err).ToNot(HaveOccurred()) Expect(originalScrobbler.GetNowPlayingCalled()).To(BeTrue()) // Simulate plugin becoming unavailable (e.g., during reload) mockPlugin.mu.Lock() delete(mockPlugin.scrobblers, "plugin1") mockPlugin.mu.Unlock() originalScrobbler.nowPlayingCalled.Store(false) // NowPlaying should return error when plugin unavailable err = pluginScr.NowPlaying(ctx, "u-1", &track, 0) Expect(err).To(HaveOccurred()) Expect(originalScrobbler.GetNowPlayingCalled()).To(BeFalse()) // Simulate plugin becoming available again mockPlugin.mu.Lock() mockPlugin.scrobblers["plugin1"] = reloadedScrobbler mockPlugin.mu.Unlock() // Should work again with new instance err = pluginScr.NowPlaying(ctx, "u-1", &track, 0) Expect(err).ToNot(HaveOccurred()) Expect(reloadedScrobbler.GetNowPlayingCalled()).To(BeTrue()) }) It("IsAuthorized uses the current plugin instance", func() { scrobblers := pTracker.getActiveScrobblers() pluginScr := scrobblers["plugin1"] // Original is authorized Expect(pluginScr.IsAuthorized(ctx, "u-1")).To(BeTrue()) // Replace with unauthorized scrobbler unauthorizedScrobbler := &fakeScrobbler{Authorized: false} mockPlugin.mu.Lock() mockPlugin.scrobblers["plugin1"] = unauthorizedScrobbler mockPlugin.mu.Unlock() // Should reflect the new scrobbler's authorization status Expect(pluginScr.IsAuthorized(ctx, "u-1")).To(BeFalse()) }) }) }) var _ = DescribeTable("remainingTTL", func(durationSec float32, positionMs int64, rate float64, expected time.Duration) { Expect(remainingTTL(durationSec, positionMs, rate)).To(Equal(expected)) }, Entry("full track at 1x", float32(300), int64(0), 1.0, 305*time.Second), Entry("halfway through at 1x", float32(300), int64(150000), 1.0, 155*time.Second), Entry("near end at 1x", float32(300), int64(298000), 1.0, 7*time.Second), Entry("at end of track", float32(300), int64(300000), 1.0, 5*time.Second), Entry("past end of track", float32(300), int64(310000), 1.0, 5*time.Second), Entry("2x speed halves remaining time", float32(300), int64(0), 2.0, 155*time.Second), Entry("2x speed halfway", float32(300), int64(150000), 2.0, 80*time.Second), Entry("0.5x speed doubles remaining time", float32(300), int64(0), 0.5, 605*time.Second), Entry("zero rate defaults to 1x", float32(300), int64(0), 0.0, 305*time.Second), Entry("negative rate defaults to 1x", float32(300), int64(0), -1.0, 305*time.Second), Entry("short track", float32(3.5), int64(0), 1.0, 8*time.Second), Entry("zero duration", float32(0), int64(0), 1.0, 5*time.Second), ) type fakeScrobbler struct { Authorized bool nowPlayingCalled atomic.Bool ScrobbleCalled atomic.Bool PlaybackReportCalled atomic.Bool userID atomic.Pointer[string] username atomic.Pointer[string] track atomic.Pointer[model.MediaFile] position atomic.Int32 LastScrobble atomic.Pointer[Scrobble] LastPlaybackReport atomic.Pointer[PlaybackSession] Error error } func (f *fakeScrobbler) GetNowPlayingCalled() bool { return f.nowPlayingCalled.Load() } func (f *fakeScrobbler) GetUserID() string { if p := f.userID.Load(); p != nil { return *p } return "" } func (f *fakeScrobbler) GetTrack() *model.MediaFile { return f.track.Load() } func (f *fakeScrobbler) IsAuthorized(ctx context.Context, userId string) bool { return f.Error == nil && f.Authorized } func (f *fakeScrobbler) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error { f.nowPlayingCalled.Store(true) if f.Error != nil { 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 } func (f *fakeScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error { f.userID.Store(&userId) f.LastScrobble.Store(&s) f.ScrobbleCalled.Store(true) if f.Error != nil { return f.Error } return nil } func (f *fakeScrobbler) PlaybackReport(ctx context.Context, info PlaybackSession) error { f.PlaybackReportCalled.Store(true) if f.Error != nil { return f.Error } f.userID.Store(new(info.UserId)) f.LastPlaybackReport.Store(&info) return nil } func _p(id, name string, sortName ...string) model.Participant { p := model.Participant{Artist: model.Artist{ID: id, Name: name}} if len(sortName) > 0 { p.Artist.SortArtistName = sortName[0] } return p } type fakeEventBroker struct { http.Handler events []events.Event mu sync.Mutex } func (f *fakeEventBroker) SendMessage(_ context.Context, event events.Event) { f.mu.Lock() defer f.mu.Unlock() f.events = append(f.events, event) } func (f *fakeEventBroker) SendBroadcastMessage(_ context.Context, event events.Event) { f.mu.Lock() defer f.mu.Unlock() f.events = append(f.events, event) } func (f *fakeEventBroker) getEvents() []events.Event { f.mu.Lock() defer f.mu.Unlock() return f.events } var _ events.Broker = (*fakeEventBroker)(nil) // mockBufferedScrobbler used to test that Stop is called type mockBufferedScrobbler struct { wrapped Scrobbler stopCalled bool } func (m *mockBufferedScrobbler) Stop() { m.stopCalled = true } func (m *mockBufferedScrobbler) IsAuthorized(ctx context.Context, userId string) bool { return m.wrapped.IsAuthorized(ctx, userId) } func (m *mockBufferedScrobbler) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error { return m.wrapped.NowPlaying(ctx, userId, track, position) } func (m *mockBufferedScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error { return m.wrapped.Scrobble(ctx, userId, s) } func (m *mockBufferedScrobbler) PlaybackReport(ctx context.Context, info PlaybackSession) error { return m.wrapped.PlaybackReport(ctx, info) }