mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
* fix(scrobbler): tolerate out-of-order playback reports Clients may fire reportPlayback requests concurrently (Feishin sends 'starting' and 'playing' in parallel, and the previous track's 'stopped' races the next track's start), so reports can be processed out of order. Two cases corrupted the now-playing session: a late 'starting' for the track already playing downgraded the session state, freezing position estimation at 0:00 until the next report; and a late 'stopped' for the previous track removed the new track's session and dispatched a playback report mislabeled with the new track's metadata, causing presence-style plugins (e.g. Discord Rich Presence) to clear or show stale state. ReportPlayback now ignores a 'starting' report when the session already has the same track in playing state, and ignores a 'stopped' report for a track other than the current session's - skipping both the session removal and the plugin dispatch, while still counting the play and dispatching external scrobbles for the stopped track. Reported in https://github.com/jeffvli/feishin/issues/2131 * fix(scrobbler): serialize session writes to close starting/playing race The out-of-order 'starting' guard checked the session cache before the media-file load, leaving a window where a concurrent 'playing' report on a fresh session could write between the check and the write, and still be overwritten back to 'starting'. Session check-then-write sections are now serialized by a mutex, with the guard re-checked after the load. Also tightens the guard comment to say 'playing session', matching the condition. Found by Codex review on #5793. * fix(scrobbler): fully exit ReportPlayback when ignoring out-of-order reports The out-of-order guards used 'break', which only exits the switch, so the post-switch NowPlaying block still ran for an ignored 'starting' report and enqueued a NowPlaying dispatch with the stale report's position - potentially overwriting a pending correct-position entry, since the queue is keyed by client. Return nil instead, so ignored reports have no side effects. Found by Gemini review on #5793.
1356 lines
50 KiB
Go
1356 lines
50 KiB
Go
package scrobbler
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"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
|
|
}
|
|
|
|
// slowMediaFileRepo widens the window between a report's session check and its
|
|
// write, making check-then-write races reproducible.
|
|
type slowMediaFileRepo struct {
|
|
model.MediaFileRepository
|
|
}
|
|
|
|
func (s *slowMediaFileRepo) GetWithParticipants(id string) (*model.MediaFile, error) {
|
|
time.Sleep(5 * time.Millisecond)
|
|
return s.MediaFileRepository.GetWithParticipants(id)
|
|
}
|
|
|
|
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("IsBuiltinScrobbler", func() {
|
|
It("reports whether the name belongs to a registered builtin scrobbler", func() {
|
|
Expect(IsBuiltinScrobbler("fake")).To(BeTrue())
|
|
Expect(IsBuiltinScrobbler("some-plugin")).To(BeFalse())
|
|
})
|
|
})
|
|
|
|
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.Unix()))
|
|
})
|
|
|
|
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 when switching tracks on same player", func() {
|
|
track2 := track
|
|
track2.ID = "456"
|
|
_ = ds.MediaFile(ctx).Put(&track2)
|
|
|
|
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: "456", 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].MediaFile.ID).To(Equal("456"))
|
|
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() {
|
|
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,
|
|
IgnoreScrobble: true,
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(track.PlayCount).To(Equal(int64(0)))
|
|
Consistently(func() bool { return fake.ScrobbleCalled.Load() }).Should(BeFalse())
|
|
})
|
|
|
|
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("resilience (out-of-order reports)", func() {
|
|
BeforeEach(func() {
|
|
track2 := track
|
|
track2.ID = "456"
|
|
_ = ds.MediaFile(ctx).Put(&track2)
|
|
})
|
|
|
|
It("does not downgrade an actively playing session when a late starting report arrives for the same track", func() {
|
|
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
|
MediaId: "123", PositionMs: 1000, 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("playing"))
|
|
Expect(playing[0].PositionMs).To(BeNumerically(">=", int64(1000)))
|
|
})
|
|
|
|
It("keeps the current session when a stopped report arrives for a different track", func() {
|
|
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
|
MediaId: "456", PositionMs: 0, State: "playing", 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())
|
|
|
|
playing, err := tracker.GetNowPlaying(ctx)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(playing).To(HaveLen(1))
|
|
Expect(playing[0].MediaFile.ID).To(Equal("456"))
|
|
Expect(playing[0].State).To(Equal("playing"))
|
|
})
|
|
|
|
It("still auto-scrobbles the stopped track when the current session is for a different track", func() {
|
|
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
|
MediaId: "456", PositionMs: 0, State: "playing", 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)))
|
|
})
|
|
|
|
It("does not dispatch NowPlaying from an ignored out-of-order starting report", func() {
|
|
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
|
MediaId: "123", PositionMs: 60000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
|
|
fake.nowPlayingCalled.Store(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())
|
|
})
|
|
|
|
It("never lets a concurrent starting report downgrade the playing session", func() {
|
|
ds.(*tests.MockDataStore).MockedMediaFile = &slowMediaFileRepo{MediaFileRepository: ds.MediaFile(ctx)}
|
|
for i := range 20 {
|
|
raceClientId := fmt.Sprintf("race-client-%d", i)
|
|
var wg sync.WaitGroup
|
|
wg.Add(2)
|
|
go func() {
|
|
defer wg.Done()
|
|
defer GinkgoRecover()
|
|
_ = tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
|
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: raceClientId,
|
|
})
|
|
}()
|
|
go func() {
|
|
defer wg.Done()
|
|
defer GinkgoRecover()
|
|
_ = tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
|
MediaId: "123", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: raceClientId,
|
|
})
|
|
}()
|
|
wg.Wait()
|
|
info, err := tracker.playMap.Get(raceClientId)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(info.State).To(Equal("playing"), "iteration %d", i)
|
|
}
|
|
})
|
|
|
|
It("does NOT forward a stopped report for a different track to playback reporters", func() {
|
|
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
|
MediaId: "456", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
|
|
})
|
|
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: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
Consistently(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeFalse())
|
|
})
|
|
})
|
|
|
|
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("still dispatches NowPlaying 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())
|
|
Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
|
|
})
|
|
|
|
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) GetUsername() string {
|
|
if p := f.username.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)
|
|
// 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 {
|
|
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)
|
|
}
|