mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
* feat(agents): retry-later error type with optional server delay
Add agents.ErrRetryLater and agents.RetryLaterError, which carries the
delay requested by an external service (e.g. ListenBrainz's
X-RateLimit-Reset-In). scrobbler.ErrRetryLater becomes an alias of the new
sentinel, so existing errors.Is checks and the plugin error-string protocol
keep working unchanged. Groundwork for honoring server-requested retry
delays across scrobbling, metadata agents and artwork.
Song.Equals tests moved to song_test.go to enable external test package.
* fix(scrobbler): honor backoff window and server-requested retry delay
ListenBrainz 429s were decoded into a typed error that classified as
unrecoverable, silently discarding the scrobble (a JSON-bodied 429 was
measured live). The client now maps any 429 to agents.RetryLaterError,
carrying X-RateLimit-Reset-In when present (capped at 1h). Last.fm error 29
(rate limit) is now retryable like 11/16. The buffer's drain loop no longer
lets wake signals bypass an active backoff window - new plays enqueue but
drain only when the window closes - and the wait honors the server delay
via max(backoff, retryIn).
* feat(agents): skip cooling-down agents in aggregate calls
When an agent reports retry-later, remember a per-agent cooldown deadline
(the server-requested delay, or 1 minute when unspecified) and skip that
agent in all aggregate metadata calls until it passes. A round that found
no data but skipped or saw a throttled agent returns ErrRetryLater instead
of ErrNotFound, so callers cannot mistake rate limiting for a definitive
'no data' answer.
* feat(artwork): honor server-requested retry delay when rescheduling
When an external image lookup fails with a retry-later error carrying a
delay (e.g. a 429 with X-RateLimit-Reset-In), the chain trace carries the
largest such hint back to the worker, which reschedules the item at
max(exponential backoff, server delay) instead of backoff alone.
* feat(plugins): retry-later with optional delay for scrobbler and agent plugins
Scrobbler plugins can now return scrobbler(retry_later:N) to request a
retry in N seconds (capped at 1h); the bare token keeps its old meaning.
Metadata-agent plugins, which had no error vocabulary at all, gain the
parallel agent(retry_later[:N]) token, mapped to agents.RetryLaterError so
the aggregate's cooldown and the artwork worker honor plugin throttling
the same way as built-in agents.
* fix: address whole-branch review findings for retry-later handling
Narrow the aggregate's throttled rule to the spec sentence: core.Agents returns
ErrRetryLater only when no agent answered at all (all skipped-cooling or
retry-later). An agent that does not implement the called method now returns an
internal errUnsupported instead of ErrNotFound, so it counts as "did not run" —
without that, the always-appended local agent would answer for biography, URL
and images and make ErrRetryLater unreachable.
Wire the consequence in core/external: a throttled round no longer stamps
ExternalInfoUpdatedAt (artist and album), so the empty result is not cached for
the TTL, and TopSongs maps ErrRetryLater to the same empty-200 the not-found
path already produced instead of a new client-facing error.
Move the Last.fm code-29 mapping into the client's central error construction so
every metadata path produces RetryLaterError, and map ListenBrainz's body-level
code 429 (sent with a non-429 HTTP status) the same way.
Clamp server- and plugin-requested delays in seconds before scaling to a
Duration, in all three parse sites: a header of 18446744074 wrapped past 2^64 and
came out as a 0.29s delay.
Also: extract the artwork worker's reschedule computation into retryDelay() and
cover both it and the trace RetryIn wiring with tests; collapse the double regex
call in mapScrobblerError; drop capabilities.ScrobblerErrorRetryLaterIn (ndpgen
never emits funcs, so plugin authors could not reach it); regenerate the PDKs so
MetadataAgentError reaches the Go and Rust SDKs; de-flake the cooldown tests
(long RetryIn for the skip case, separate expiry spec); and cover the max()
retry-delay aggregation across users in the scrobble buffer.
* refactor: dedupe retry-later parsing and simplify error collection
- Add agents.NewRetryLater and agents.RetryLaterFromSeconds, with a single
1h cap, replacing the parse+clamp+multiply logic and the maxRetryInSeconds
constant duplicated across listenbrainz, plugins and the agent adapter.
- Move HTTP header parsing to httpclient.RetryAfter, so the transport layer
owns it and stays domain-agnostic; drop retryInFromHeaders from the
ListenBrainz client. Covered by a new Ginkgo table in that package.
- Collapse the two near-identical plugin retry_later regexes into one
parseRetryLater(prefix, msg) shared by the agent and scrobbler adapters.
- Fold the duplicated noteRetryIn snippet from fetchArtistImage and
fetchAlbumImage into recordAgent, which already branched on the same
isTransientExternal condition.
- Replace the atomic.Bool + note() closure in populateArtistInfo with
errgroup's own error collection; the group carries no context, so a
returned error does not cancel its siblings.
- Reuse recoveringScrobbler for the per-user delay test instead of a third
double, and switch fakeScrobbler's mutex-guarded error to the
atomic.Pointer idiom already used in the same package.
* refactor(listenbrainz): keep rate-limit header parsing in the adapter
The X-RateLimit-Reset-In header is ListenBrainz's own convention, not a
shared one: Last.fm sends no rate-limit headers at all and reports its
limit as a body code, and no other integration in tree sends Retry-After.
A parser in utils/httpclient implied a uniformity across services that
does not exist, so it moves back next to the only client that can know
which header its service sends.
* refactor(agents): collapse the retry-later sentinel and error into one type
ErrRetryLater is now the zero-delay RetryLaterError rather than a separate
errors.New value, so errors.Is and errors.AsType both match the sentinel and
every delay-carrying variant. That removes the trap where a bare sentinel
silently skipped the AsType path, and lets every consumer read the delay off
the error directly: the RetryIn accessor and the two constructors are gone,
with the policy cap applied where untrusted input is parsed.
* refactor(agents): split the cooldown store from the per-dispatch tally
The cooldown map and mutex become a cooldowns value with active/park, holding
no knowledge of errors; agentAttempts records one dispatch's outcomes and owns
the classification that noteAgentError used to hide behind a bool. The three
dispatch loops now touch a single object: skip folds the cooldown check and the
throttled flag into one call, so the store never appears in the loops.
* refactor(agents): share one dispatch loop between the agent call helpers
callAgentMethod and callAgentSliceMethod ran identical loops, differing only in
how they test a result for emptiness: a slice cannot be compared against its
zero value, so the two could not share a constraint. Both now delegate to
callAgent, which takes that test as a parameter. Keeping the loop in one place
matters more than the lines saved: it holds the cooldown skip, the attempt
recording and the empty-dispatch verdict, and a fix applied to one copy but not
the other would be silent.
* test: cover the two retry-later paths a mutation could break silently
Both gaps were proven, not guessed: making the artwork worker pass 0 instead
of the collected hint left all 386 specs green, and replacing the default
agent cooldown with 0 left the agents suite green. The worker test drives a
throttled image agent through drain and asserts the persisted retry_at, and
the cooldown test parks an agent that asked to be retried without naming a
delay, which is what Last.fm does on every rate limit.
* refactor(artwork): carry the external failure as an error, not a flag plus a trace field
The retry delay was riding on ChainTrace, a diagnostic that gets persisted, while
the very same signal — an external source faulted — already travelled by value as
resolution.extError. That was two mechanisms for one idea, and it put control-flow
state inside a serializable trace.
resolution.extError and chainState.extErr become the error itself, so a caller
checks err != nil for the fault and errors.AsType for the delay the provider asked
for. The agent loops return that error last, per convention, and longerRetry keeps
whichever failure wants the longer wait. ChainTrace goes back to holding only steps
and no longer imports core/agents.
* fix(artwork): check the resolve error before reading its resolution
Reading res.extError before the err check was safe only because every error path
in resolve returns a bare resolution{}; a future path returning a partly-filled
one would have been read silently. The failure path now returns no delay
explicitly.
* test(artwork): assert the delay acquire reports, not just its downstream effect
acquire's retry delay was only covered through the worker's persisted retry_at,
one layer away from where the value is computed. Both outcomes are now pinned at
the processor: a plain failure asks for nothing, a throttled provider's delay is
passed through.
* refactor: share the retry-seconds parse and drop the backoff deadline arithmetic
The clamp-before-scaling invariant lived in two parsers and was independently
re-tested in three files with the same magic number; a fix applied to one copy
would have left the others wrapping a huge value down to a fraction of a second.
It moves to agents.ParseRetryIn.
The buffer tracked an absolute retryDeadline only to re-arm a timer that was
already armed for the same instant; a backingOff flag says the same thing without
the arithmetic. The plugin token regex now carries its capability in the pattern
instead of capturing and comparing, so another capability's token in the same
message cannot mask it. resolution.extError becomes extErr, matching its
chainState counterpart.
* fix(agents): keep the longer cooldown when parks overlap
Calls to one agent overlap, so a short cooldown could land after a long one
started and cut it short. park now keeps whichever deadline is later, matching
the rule longerRetry already applies on the artwork side. No in-tree provider
can currently produce two different delays for the same agent, so this is
hardening rather than a fix for observed behaviour.
* fix(agents): parse the retry delay at a fixed width
strconv.Atoi parses into the native int, so on the 32-bit targets we ship
(linux/386, windows/386, three ARM variants) a delay above MaxInt32 seconds
overflowed and became unspecified instead of being capped. No provider sends a
68-year delay, so this is not user-visible, but the overflow tests asserted the
cap and would have failed on those architectures, where tests never run.
* fix(plugins): anchor the retry_later regex to a word boundary
Prevents a superstring like useragent(retry_later) from matching the
agent capability token.
1575 lines
57 KiB
Go
1575 lines
57 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/criteria"
|
|
"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
|
|
}
|
|
|
|
// flipOnPlayRepo reports one filter verdict before the play is counted and another
|
|
// after, reproducing a filter that reads annotations incPlay mutates.
|
|
type flipOnPlayRepo struct {
|
|
model.MediaFileRepository
|
|
before, after bool
|
|
played atomic.Bool
|
|
}
|
|
|
|
func (r *flipOnPlayRepo) IncPlayCount(id string, ts time.Time) error {
|
|
r.played.Store(true)
|
|
return r.MediaFileRepository.IncPlayCount(id, ts)
|
|
}
|
|
|
|
func (r *flipOnPlayRepo) MatchesCriteria(string, criteria.Criteria) (bool, error) {
|
|
if r.played.Load() {
|
|
return r.after, nil
|
|
}
|
|
return r.before, nil
|
|
}
|
|
|
|
// 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.SetError(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("Scrobble filter", func() {
|
|
var repo *tests.MockMediaFileRepo
|
|
|
|
BeforeEach(func() {
|
|
ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1",
|
|
ScrobbleFilter: `{"all":[{"contains":{"title":"Track"}}]}`})
|
|
repo = ds.MediaFile(ctx).(*tests.MockMediaFileRepo)
|
|
})
|
|
|
|
It("does not send a matching track to the agent", func() {
|
|
repo.MatchesCriteriaValue = true
|
|
|
|
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(fake.ScrobbleCalled.Load()).To(BeFalse())
|
|
})
|
|
|
|
It("still increments play counts for a filtered track", func() {
|
|
repo.MatchesCriteriaValue = true
|
|
|
|
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(track.PlayCount).To(Equal(int64(1)))
|
|
Expect(album.PlayCount).To(Equal(int64(1)))
|
|
})
|
|
|
|
It("sends a non-matching track to the agent", func() {
|
|
repo.MatchesCriteriaValue = false
|
|
|
|
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(fake.ScrobbleCalled.Load()).To(BeTrue())
|
|
})
|
|
|
|
It("fails open when evaluation errors", func() {
|
|
repo.MatchesCriteriaErr = errors.New("boom")
|
|
|
|
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(fake.ScrobbleCalled.Load()).To(BeTrue())
|
|
})
|
|
|
|
It("fails open when the stored filter is not valid JSON", func() {
|
|
ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1", ScrobbleFilter: `{broken`})
|
|
repo.MatchesCriteriaValue = true
|
|
|
|
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(fake.ScrobbleCalled.Load()).To(BeTrue())
|
|
})
|
|
|
|
It("does not send now-playing for a filtered track", func() {
|
|
repo.MatchesCriteriaValue = true
|
|
|
|
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
|
MediaId: "123", State: StateStarting, ClientId: "player-1", ClientName: "player"})
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse())
|
|
})
|
|
|
|
It("does not send playback reports for a filtered track", func() {
|
|
repo.MatchesCriteriaValue = true
|
|
|
|
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
|
MediaId: "123", State: StateStarting, ClientId: "player-1", ClientName: "player"})
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Consistently(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeFalse())
|
|
})
|
|
|
|
It("sends playback reports for a non-matching track", func() {
|
|
repo.MatchesCriteriaValue = false
|
|
|
|
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
|
MediaId: "123", State: StateStarting, ClientId: "player-1", ClientName: "player"})
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue())
|
|
})
|
|
|
|
It("evaluates the filter even when no scrobbler is active yet", func() {
|
|
// The verdict is stored on the session and dispatched at expiry, by which
|
|
// time a plugin scrobbler may have been enabled.
|
|
tracker.builtinScrobblers = map[string]Scrobbler{}
|
|
repo.MatchesCriteriaValue = true
|
|
|
|
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
|
MediaId: "123", State: StatePlaying, ClientId: "player-12", ClientName: "player"})
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
stored, getErr := tracker.playMap.Get("player-12")
|
|
Expect(getErr).ToNot(HaveOccurred())
|
|
Expect(stored.filtered).To(BeTrue())
|
|
})
|
|
|
|
Context("when incPlay itself flips the filter", func() {
|
|
// A filter on playCount or lastPlayed changes verdict the moment incPlay
|
|
// commits, so the verdict has to be taken before it, not at dispatch time.
|
|
var flip *flipOnPlayRepo
|
|
|
|
install := func(before, after bool) {
|
|
flip = &flipOnPlayRepo{MediaFileRepository: ds.MediaFile(ctx), before: before, after: after}
|
|
ds.(*tests.MockDataStore).MockedMediaFile = flip
|
|
}
|
|
|
|
It("does not scrobble a track the filter matched before the play was counted", func() {
|
|
install(true, false)
|
|
|
|
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
|
MediaId: "123", State: StateStopped, PositionMs: 120_000, ClientId: "player-1", ClientName: "player"})
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(flip.played.Load()).To(BeTrue(), "incPlay must still have run")
|
|
Expect(fake.ScrobbleCalled.Load()).To(BeFalse())
|
|
})
|
|
|
|
It("still sends the stopped report when the filter only starts matching after the play", func() {
|
|
install(false, true)
|
|
|
|
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
|
MediaId: "123", State: StateStopped, PositionMs: 120_000, ClientId: "player-1", ClientName: "player"})
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(flip.played.Load()).To(BeTrue(), "incPlay must still have run")
|
|
Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue())
|
|
})
|
|
|
|
It("does not report an expired session for a filtered track", func() {
|
|
// The expiry callback runs with a stub user, so it cannot evaluate the
|
|
// filter itself and must reuse the verdict stored on the session.
|
|
info := PlaybackSession{
|
|
MediaFile: track, Start: time.Now(), UserId: "u-1", Username: "user-1",
|
|
PlayerId: "player-9", PlayerName: "test-player", State: StatePlaying, filtered: true,
|
|
}
|
|
_ = tracker.playMap.AddWithTTL("player-9", info, 10*time.Millisecond)
|
|
|
|
Consistently(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeFalse())
|
|
})
|
|
|
|
It("still reports an expired session for a track that is not filtered", func() {
|
|
info := PlaybackSession{
|
|
MediaFile: track, Start: time.Now(), UserId: "u-1", Username: "user-1",
|
|
PlayerId: "player-10", PlayerName: "test-player", State: StatePlaying, filtered: false,
|
|
}
|
|
_ = tracker.playMap.AddWithTTL("player-10", info, 10*time.Millisecond)
|
|
|
|
Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue())
|
|
})
|
|
|
|
It("stores the verdict on the session so expiry can reuse it", func() {
|
|
repo.MatchesCriteriaValue = true
|
|
|
|
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
|
MediaId: "123", State: StatePlaying, ClientId: "player-11", ClientName: "player"})
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
stored, getErr := tracker.playMap.Get("player-11")
|
|
Expect(getErr).ToNot(HaveOccurred())
|
|
Expect(stored.filtered).To(BeTrue())
|
|
})
|
|
|
|
It("does not scrobble a Submit whose filter matched before the play was counted", func() {
|
|
install(true, false)
|
|
|
|
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(flip.played.Load()).To(BeTrue(), "incPlay must still have run")
|
|
Expect(fake.ScrobbleCalled.Load()).To(BeFalse())
|
|
})
|
|
})
|
|
})
|
|
|
|
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]
|
|
err atomic.Pointer[error]
|
|
scrobbleAttempts atomic.Int32
|
|
}
|
|
|
|
// SetError sets the error returned by IsAuthorized/NowPlaying/Scrobble/PlaybackReport.
|
|
func (f *fakeScrobbler) SetError(err error) {
|
|
f.err.Store(&err)
|
|
}
|
|
|
|
func (f *fakeScrobbler) getError() error {
|
|
if e := f.err.Load(); e != nil {
|
|
return *e
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ScrobbleAttempts returns how many times Scrobble was called.
|
|
func (f *fakeScrobbler) ScrobbleAttempts() int32 {
|
|
return f.scrobbleAttempts.Load()
|
|
}
|
|
|
|
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.getError() == nil && f.Authorized
|
|
}
|
|
|
|
func (f *fakeScrobbler) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
|
|
f.nowPlayingCalled.Store(true)
|
|
if err := f.getError(); err != nil {
|
|
return err
|
|
}
|
|
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)
|
|
f.scrobbleAttempts.Add(1)
|
|
if err := f.getError(); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeScrobbler) PlaybackReport(ctx context.Context, info PlaybackSession) error {
|
|
f.PlaybackReportCalled.Store(true)
|
|
if err := f.getError(); err != nil {
|
|
return err
|
|
}
|
|
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)
|
|
}
|