* feat(req): add Float64Or helper for parsing float query params * feat(scrobbler): extend NowPlayingInfo with state/position/rate fields * feat(scrobbler): implement ReportPlayback with state machine and auto-scrobble * feat(responses): add state/positionMs/playbackRate to NowPlayingEntry * feat(subsonic): add reportPlayback endpoint handler * feat(subsonic): include state/positionMs/playbackRate in getNowPlaying response * feat(subsonic): register playbackReport OpenSubsonic extension * test(e2e): add reportPlayback endpoint e2e tests * refactor(scrobbler): simplify ReportPlayback — extract helpers, remove duplication - Add state constants and exported ValidStates map - Extract remainingTTL() helper (was duplicated 3x) - Merge playing/paused switch cases into single branch - Use Get instead of GetWithParticipants for non-stopped states - Guard NowPlayingCount broadcast with count-change detection - Use cache entry for NowPlaying dispatch instead of extra DB query - Remove redundant Position field from NowPlayingInfo * refactor(scrobbler): skip DB query in playing/paused when playMap has entry * fix(play_tracker): handle errors when adding/updating NowPlayingInfo in cache Signed-off-by: Deluan <deluan@navidrome.org> * refactor(play_tracker): replace sort with slices.SortFunc for NowPlayingInfo Signed-off-by: Deluan <deluan@navidrome.org> * fix(play_tracker): check all ReportPlayback errors in tests Replace _ = with explicit error assertions to avoid masking failures in intermediate calls. Signed-off-by: Deluan <deluan@navidrome.org> * test(e2e): use real PlayTracker and assert getNowPlaying after reportPlayback Replace noopPlayTracker with a real PlayTracker backed by the E2E database. E2E tests now verify the full round-trip: reportPlayback creates/updates/removes entries visible via getNowPlaying, including state, positionMs, and playbackRate fields. Export NewPlayTracker constructor for use outside the scrobbler package. * fix(play_tracker): account for playback rate in TTL and detect track switches The remainingTTL function now divides remaining time by the playback rate, so cache entries expire correctly at non-1x speeds (e.g., 2x playback halves the TTL). Zero/negative rates default to 1.0. The playing/paused case now checks if the cached MediaFile ID matches the reported mediaId, falling back to a DB fetch when the client switches tracks without sending stopped/starting. Adds parameterized tests for remainingTTL covering rate variations and edge cases. * fix(subsonic): validate positionMs and playbackRate in reportPlayback Reject negative positionMs values and invalid playbackRate values (NaN, Inf, zero, negative) at the API boundary before they reach TTL and position estimation math. Returns clear error messages for each case. * feat(play_tracker): add ClientId and ClientName to ReportPlayback parameters Signed-off-by: Deluan <deluan@navidrome.org> * refactor(play_tracker): replace NowPlaying method with ReportPlayback calls Signed-off-by: Deluan <deluan@navidrome.org> * refactor(play_tracker_test): remove redundant TTL behavior tests and clean up mockPluginLoader Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
992 lines
36 KiB
Go
992 lines
36 KiB
Go
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.(*playTracker).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.(*playTracker).stopNowPlayingWorker()
|
|
})
|
|
|
|
It("does not register disabled scrobblers", func() {
|
|
Expect(tracker.(*playTracker).builtinScrobblers).To(HaveKey("fake"))
|
|
Expect(tracker.(*playTracker).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 := NowPlayingInfo{MediaFile: track, Start: time.Now(), Username: "user"}
|
|
_ = tracker.(*playTracker).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 := NowPlayingInfo{MediaFile: track, Start: time.Now(), Username: "user"}
|
|
_ = tracker.(*playTracker).playMap.AddWithTTL("player-2", info, 10*time.Millisecond)
|
|
Consistently(func() int { return len(eventBroker.getEvents()) }).Should(Equal(0))
|
|
})
|
|
})
|
|
|
|
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("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("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.(*playTracker).builtinScrobblers["fake"] = fake
|
|
tracker.(*playTracker).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.stopNowPlayingWorker()
|
|
})
|
|
|
|
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
|
|
userID atomic.Pointer[string]
|
|
username atomic.Pointer[string]
|
|
track atomic.Pointer[model.MediaFile]
|
|
position atomic.Int32
|
|
LastScrobble atomic.Pointer[Scrobble]
|
|
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 _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)
|
|
}
|