quintodrome/core/scrobbler/play_tracker_test.go
Deluan Quintão 7e16b6acb5
feat(ui): replace UI scrobble with reportPlayback and redesign NowPlaying panel (#5448)
* feat(config): add UIPlaybackReportInterval setting

* feat(server): expose playbackReportIntervalMs to UI config

* feat(ui): add playbackReportIntervalMs config default

* feat(ui): replace scrobble/nowPlaying with reportPlayback in subsonic API layer

* feat(ui): replace scrobble logic with reportPlayback state machine in Player

* refactor(ui): simplify Player heartbeat using useInterval hook

- Replace manual setInterval/clearInterval with existing useInterval hook
- Extract shared reportPlaybackUrl helper to deduplicate URL construction
- Use ref for currentTrackId in beforeunload to stabilize effect deps
- Have heartbeat read lastPositionMsRef instead of audioInstance.currentTime

* feat(ui): redesign NowPlaying panel with Discord-style layout

Show album art with play/pause overlay icon, track title, artist,
album name, progress bar with position/duration, and username.

* fix(ui): adjust NowPlaying panel height to fit 3 entries

* fix(ui): send stopped on player close and tab close while paused

- onBeforeDestroy now sends reportPlayback stopped before clearing queue
- beforeunload sends stopped beacon regardless of pause state

* feat(ui): animate NowPlaying progress bar with 1s client-side tick

* fix(ui): account for playbackRate in NowPlaying progress interpolation

* fix(ui): use timestamp-based interpolation for smooth NowPlaying progress

Replace tick counter with fetchedAt timestamp so the progress bar
advances smoothly without resetting on each server poll.

* fix(ui): fix NowPlaying progress bar not animating

Pass `now` (Date.now()) as a prop that changes every tick, so
React.memo'd components actually re-render each second.

* fix(ui): prevent progress bar reset on NowPlaying poll

Set fetchedAt and now atomically on fetch so the elapsed offset
starts at zero and the server's already-estimated positionMs
is used as the base without a visible jump.

* fix(ui): stamp entries with fetch time to prevent progress bar reset

Embed _fetchedAt timestamp directly into each entry object so the
position and its reference timestamp are always in the same state
update, eliminating the React 17 multi-setState batching race.

* fix(server): estimate position for starting state in GetNowPlaying

GetNowPlaying was only estimating elapsed position for the "playing"
state, returning raw positionMs=0 for "starting". Since the UI
player sends "starting" once and then doesn't update until the
60s heartbeat, NowPlaying polls returned 0 for up to a minute,
causing the progress bar to reset on every poll.

* fix(ui): send playing immediately after starting to enable position estimation

The server only estimates elapsed position for "playing" state in
GetNowPlaying. The Player was sending "starting" once and then not
updating until the 60s heartbeat, leaving the server state as
"starting" with positionMs=0 for up to a minute.

Now the Player follows up "starting" with an immediate "playing"
call, transitioning the server state so position estimation works
from the first poll.

* fix(subsonic): fix getNowPlaying returning same playerId for all entries

PlayerId was never incremented in the map callback, so every entry
got playerId=1. This caused the UI to use duplicate React keys,
mixing up rendered entries between players. Also use a stable
composite key in the UI instead of the sequential playerId.

* fix(ui): only send stopped beacon when tab is actually closing

Move the reportPlaybackBeacon call from beforeunload to pagehide.
beforeunload fires before the confirmation dialog, so cancelling
the close would still send stopped. pagehide only fires when the
page is actually being unloaded.

* fix(ui): revert to beforeunload for stopped beacon

pagehide does not fire reliably in Chrome when closing tabs.
Use beforeunload instead — if the user cancels the close dialog,
the heartbeat will re-register the NowPlaying entry on its next tick.

* fix(ui): use synchronous XHR for stopped report on tab close

Replace sendBeacon with synchronous XMLHttpRequest in beforeunload.
This blocks the page from closing until the server acknowledges
the stopped state, ensuring the NowPlaying entry is always removed.

* fix(ui): fix confirmation dialog and use fetch keepalive for tab close

- Move e.preventDefault() before the stopped report so the dialog
  always shows regardless of XHR errors
- Use fetch with keepalive:true instead of sync XHR (more reliable,
  non-blocking, survives page teardown)
- Fall back to sendBeacon if fetch throws

* fix(ui): prevent heartbeat from re-adding entry after stopped on tab close

Set a stoppedRef flag in beforeunload so the heartbeat interval
skips sending playing reports after stopped has been sent.
Without this, the heartbeat could re-register the NowPlaying
entry after the stopped event removed it.

* fix(ui): include client unique ID header in stopped report on tab close

Root cause: reportPlaybackSync (fetch keepalive) did not include the
X-ND-Client-Unique-Id header. Regular reportPlayback calls via
httpClient include this header, and the server uses it as the playMap
key. Without the header, the stopped call fell back to player.ID
as the key, which didn't match the entry added with the UUID key.
The playMap.Remove targeted the wrong key, so the entry persisted.

Fix: export clientUniqueId from httpClient and include it as a header
in the fetch keepalive request.

* fix(ui): use pagehide for stopped report to avoid premature send

beforeunload fires before the confirmation dialog, so the stopped
event was sent even when the user cancelled closing. Move the
stopped report to pagehide, which only fires when the page is
actually being unloaded (after confirmation).

* feat(server): broadcast NowPlaying SSE on every state change

Previously, the SSE broadcast only fired when the NowPlaying count
changed. Now it fires on every reportPlayback call (starting,
playing, paused, stopped), so the NowPlaying panel gets instant
updates for state transitions and position changes.

The UI reducer stores a nowPlayingLastUpdate timestamp alongside
the count, ensuring every SSE event triggers a re-fetch even when
the count is unchanged (e.g., pause/resume).

* fix(ui): clamp NowPlaying position to prevent negative time display

* fix(ui): debounce NowPlaying fetches to prevent progress bar trembling

During track changes, rapid SSE events (stopped, starting, playing)
triggered multiple refetches within milliseconds, each resetting the
interpolation base and causing the progress bar to oscillate. Skip
fetches within 1 second of the previous fetch.

* feat(ui): report playback position on seek

Send a reportPlayback(playing) call when the user seeks/scrubs in
the player, so the NowPlaying panel and server position stay in
sync immediately instead of waiting for the next 60s heartbeat.

* refactor: code review cleanup

- Export clientUniqueIdHeader from httpClient, use in subsonic layer
- Fix variable shadowing (now → fetchNow) in NowPlayingPanel fetchList
- Fix onBeforeDestroy nested dep (read isRadio from ref instead)
- Only broadcast SSE on state transitions, not heartbeat position updates
- Only enqueue NowPlaying to external scrobblers on state transitions

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(ui): use trailing-edge debounce for NowPlaying fetch

Replace the leading-edge throttle (which fetched on the first event
and blocked subsequent ones) with a trailing-edge debounce (300ms).
During track transitions, the burst of events (stopped → starting →
playing) now collapses into a single fetch after the burst settles,
showing the new track immediately instead of briefly showing empty.

* fix(ui): only show overlay on NowPlaying artwork when paused

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(ui): remove unnecessary sendBeacon fallback from reportPlaybackSync

* refactor(ui): rename reportPlaybackSync to reportPlaybackKeepalive

The function was never synchronous — it uses fetch with keepalive:true,
which is fire-and-forget. The name now reflects the actual behavior.

* style: format code with prettier

* test: add tests for reportPlayback SSE broadcast and UI changes

- play_tracker: verify SSE broadcast on every state transition and
  that broadcasts are skipped when EnableNowPlaying is false
- activityReducer: verify nowPlayingLastUpdate timestamp is set
- subsonic/index: verify reportPlayback URL construction

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(ui): prevent NowPlaying from fetching every second when panel is open

fetchList had unstable identity because it depended on doFetch
(which depended on notify/dispatch). Each 1s setNow re-render
recreated the callback chain, re-triggering the useEffect that
calls fetchList. Use a ref for the fetch logic so fetchList has
a stable identity with empty deps.

* fix(ui): break fetch→dispatch→effect→fetch loop in NowPlaying panel

The fetch dispatched nowPlayingCountUpdate on every result, which
updated nowPlayingLastUpdate in Redux, which triggered the SSE
effect to call fetchList again — creating a fetch loop.

Fix: remove dispatch from fetch results. The badge count uses
entries.length (from local state) when entries are loaded, falling
back to Redux count (from SSE) when they aren't. SSE events remain
the only trigger for nowPlayingLastUpdate, breaking the loop.

* fix(ui): clear NowPlaying entries on panel close so badge uses SSE count

* style: format code with prettier

* fix: address code review feedback

- Fix currentTime truthiness check to handle position 0 correctly
- Report actual player state (playing/paused) on seek instead of
  always sending 'playing'
- Remove idx from React key to avoid reorder issues
- Add debounce timer cleanup on unmount
- Keep entries on panel close so badge stays accurate from polling
- Fix test description to match actual assertion

* fix(ui): keep NowPlaying badge count accurate from polling

Add a separate nowPlayingCountSync action that updates the Redux
count without setting nowPlayingLastUpdate (which would trigger
the SSE effect and cause a fetch loop). Polling results now sync
the badge count via this action, so the badge stays accurate even
when SSE is unavailable.

* style: format code with prettier

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-05-01 15:27:32 -04:00

1055 lines
38 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("SSE broadcast on state change", func() {
BeforeEach(func() {
eventBroker = &fakeEventBroker{}
tracker = newPlayTracker(ds, eventBroker, nil)
tracker.(*playTracker).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.(*playTracker).builtinScrobblers["fake"] = fake
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Expect(eventBroker.getEvents()).To(BeEmpty())
})
})
Describe("auto-scrobble", func() {
It("scrobbles on stopped when positionMs >= 50% of track", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Expect(track.PlayCount).To(Equal(int64(1)))
Expect(album.PlayCount).To(Equal(int64(1)))
Expect(artist1.PlayCount).To(Equal(int64(1)))
})
It("scrobbles on stopped when positionMs >= 4 min for long tracks", func() {
longTrack := model.MediaFile{
ID: "long", Title: "Long Song", Album: "Album", AlbumID: "al-1",
Duration: 600,
Participants: map[model.Role]model.ParticipantList{
model.RoleArtist: []model.Participant{_p("ar-1", "Artist 1")},
},
}
_ = ds.MediaFile(ctx).Put(&longTrack)
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "long", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "long", PositionMs: 240000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Expect(longTrack.PlayCount).To(Equal(int64(1)))
})
It("does NOT scrobble when positionMs below threshold", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 10000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Expect(track.PlayCount).To(Equal(int64(0)))
})
It("does NOT scrobble when ignoreScrobble=true even if threshold met", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
IgnoreScrobble: true,
})
Expect(err).ToNot(HaveOccurred())
Expect(track.PlayCount).To(Equal(int64(0)))
})
It("does NOT scrobble when player ScrobbleEnabled=false even if threshold met", func() {
ctx = request.WithPlayer(ctx, model.Player{ID: "p1", ScrobbleEnabled: false})
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Expect(track.PlayCount).To(Equal(int64(0)))
})
It("scrobbles twice for two separate sessions of same song", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Expect(track.PlayCount).To(Equal(int64(2)))
})
It("dispatches to external scrobblers on auto-scrobble", func() {
fake.ScrobbleCalled.Store(false)
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Expect(fake.ScrobbleCalled.Load()).To(BeTrue())
})
})
Describe("position estimation", func() {
It("estimates position for playing state based on elapsed time", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
time.Sleep(50 * time.Millisecond)
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
Expect(playing[0].PositionMs).To(BeNumerically(">", int64(10000)))
})
It("does NOT estimate for paused", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 10000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
time.Sleep(50 * time.Millisecond)
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
Expect(playing[0].PositionMs).To(Equal(int64(10000)))
})
It("does NOT estimate for starting", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
time.Sleep(50 * time.Millisecond)
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
Expect(playing[0].PositionMs).To(Equal(int64(0)))
})
It("respects playbackRate", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 2.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
time.Sleep(100 * time.Millisecond)
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
// At 2x speed, 100ms real time = ~200ms playback time
Expect(playing[0].PositionMs).To(BeNumerically(">", int64(10100)))
})
It("caps estimated position at track duration", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 179990, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
time.Sleep(50 * time.Millisecond)
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
Expect(playing[0].PositionMs).To(Equal(int64(180000))) // track.Duration * 1000
})
})
Describe("resilience (no prior starting)", func() {
It("playing without prior starting creates entry with Start approx now - positionMs", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 30000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
Expect(playing[0].State).To(Equal("playing"))
expectedStart := time.Now().Add(-30 * time.Second)
Expect(playing[0].Start).To(BeTemporally("~", expectedStart, 2*time.Second))
})
It("paused without prior starting creates entry", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 30000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
Expect(playing[0].State).To(Equal("paused"))
})
It("stopped without prior starting auto-scrobbles if threshold met", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Expect(track.PlayCount).To(Equal(int64(1)))
})
It("stopped without prior starting does NOT scrobble if below threshold", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 10000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Expect(track.PlayCount).To(Equal(int64(0)))
})
})
Describe("external scrobbler dispatch", func() {
It("dispatches NowPlaying on starting", func() {
fake.nowPlayingCalled.Store(false)
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
})
It("dispatches NowPlaying on playing", func() {
fake.nowPlayingCalled.Store(false)
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
})
It("does NOT dispatch on paused", func() {
fake.nowPlayingCalled.Store(false)
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 10000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse())
})
It("does NOT dispatch when ignoreScrobble=true", func() {
fake.nowPlayingCalled.Store(false)
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
IgnoreScrobble: true,
})
Expect(err).ToNot(HaveOccurred())
Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse())
})
It("does NOT dispatch when ScrobbleEnabled=false", func() {
fake.nowPlayingCalled.Store(false)
ctx = request.WithPlayer(ctx, model.Player{ID: "p1", ScrobbleEnabled: false})
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse())
})
})
})
Describe("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)
}