diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go
index d1338ca3..462fc85b 100644
--- a/core/scrobbler/play_tracker.go
+++ b/core/scrobbler/play_tracker.go
@@ -3,7 +3,7 @@ package scrobbler
import (
"context"
"maps"
- "sort"
+ "slices"
"sync"
"time"
@@ -17,13 +17,30 @@ import (
"github.com/navidrome/navidrome/utils/singleton"
)
+const (
+ StateStarting = "starting"
+ StatePlaying = "playing"
+ StatePaused = "paused"
+ StateStopped = "stopped"
+)
+
+var ValidStates = map[string]bool{
+ StateStarting: true,
+ StatePlaying: true,
+ StatePaused: true,
+ StateStopped: true,
+}
+
type NowPlayingInfo struct {
- MediaFile model.MediaFile
- Start time.Time
- Position int
- Username string
- PlayerId string
- PlayerName string
+ MediaFile model.MediaFile
+ Start time.Time
+ Username string
+ PlayerId string
+ PlayerName string
+ State string
+ PositionMs int64
+ PlaybackRate float64
+ LastReport time.Time
}
type Submission struct {
@@ -31,6 +48,16 @@ type Submission struct {
Timestamp time.Time
}
+type ReportPlaybackParams struct {
+ MediaId string
+ PositionMs int64
+ State string
+ PlaybackRate float64
+ IgnoreScrobble bool
+ ClientId string
+ ClientName string
+}
+
type nowPlayingEntry struct {
ctx context.Context
userId string
@@ -39,9 +66,9 @@ type nowPlayingEntry struct {
}
type PlayTracker interface {
- NowPlaying(ctx context.Context, playerId string, playerName string, trackId string, position int) error
GetNowPlaying(ctx context.Context) ([]NowPlayingInfo, error)
Submit(ctx context.Context, submissions []Submission) error
+ ReportPlayback(ctx context.Context, params ReportPlaybackParams) error
}
// PluginLoader is a minimal interface for plugin manager usage in PlayTracker
@@ -72,8 +99,12 @@ func GetPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug
})
}
-// This constructor only exists for testing. For normal usage, the PlayTracker has to be a singleton, returned by
-// the GetPlayTracker function above
+// NewPlayTracker creates a new PlayTracker instance. For normal usage, the PlayTracker has to be a singleton,
+// returned by the GetPlayTracker function above. This constructor is exported for testing.
+func NewPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) PlayTracker {
+ return newPlayTracker(ds, broker, pluginManager)
+}
+
func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) *playTracker {
m := cache.NewSimpleCache[string, NowPlayingInfo]()
p := &playTracker{
@@ -193,36 +224,104 @@ func (p *playTracker) getActiveScrobblers() map[string]Scrobbler {
return combined
}
-func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerName string, trackId string, position int) error {
- mf, err := p.ds.MediaFile(ctx).GetWithParticipants(trackId)
- if err != nil {
- log.Error(ctx, "Error retrieving mediaFile", "id", trackId, err)
- return err
+func remainingTTL(durationSec float32, positionMs int64, rate float64) time.Duration {
+ if rate <= 0 {
+ rate = 1.0
}
+ remainingMs := float64(int64(durationSec*1000)-positionMs) / rate
+ remainingSec := max(int(remainingMs/1000), 0)
+ return time.Duration(remainingSec+5) * time.Second
+}
+func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackParams) error {
+ player, _ := request.PlayerFrom(ctx)
user, _ := request.UserFrom(ctx)
- info := NowPlayingInfo{
- MediaFile: *mf,
- Start: time.Now(),
- Position: position,
- Username: user.UserName,
- PlayerId: playerId,
- PlayerName: playerName,
+ clientId := params.ClientId
+ client := params.ClientName
+
+ now := time.Now()
+ prevCount := p.playMap.Len()
+
+ switch params.State {
+ case StateStarting:
+ mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId)
+ if err != nil {
+ return err
+ }
+ info := NowPlayingInfo{
+ MediaFile: *mf,
+ Start: now,
+ Username: user.UserName,
+ PlayerId: clientId,
+ PlayerName: client,
+ State: params.State,
+ PositionMs: params.PositionMs,
+ PlaybackRate: params.PlaybackRate,
+ LastReport: now,
+ }
+ err = p.playMap.AddWithTTL(clientId, info, remainingTTL(mf.Duration, params.PositionMs, params.PlaybackRate))
+ if err != nil {
+ log.Warn(ctx, "Error adding NowPlayingInfo to cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err)
+ }
+
+ case StatePlaying, StatePaused:
+ info, getErr := p.playMap.Get(clientId)
+ if getErr != nil || info.MediaFile.ID != params.MediaId {
+ mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId)
+ if err != nil {
+ return err
+ }
+ info = NowPlayingInfo{
+ MediaFile: *mf,
+ Start: now.Add(-time.Duration(params.PositionMs) * time.Millisecond),
+ Username: user.UserName,
+ PlayerId: clientId,
+ PlayerName: client,
+ }
+ }
+ info.State = params.State
+ info.PositionMs = params.PositionMs
+ info.PlaybackRate = params.PlaybackRate
+ info.LastReport = now
+ ttl := 30 * time.Minute
+ if params.State == StatePlaying {
+ ttl = remainingTTL(info.MediaFile.Duration, params.PositionMs, params.PlaybackRate)
+ }
+ err := p.playMap.AddWithTTL(clientId, info, ttl)
+ if err != nil {
+ log.Warn(ctx, "Error updating NowPlayingInfo in cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err)
+ }
+
+ case StateStopped:
+ if !params.IgnoreScrobble && player.ScrobbleEnabled {
+ mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId)
+ if err != nil {
+ return err
+ }
+ trackDurationMs := int64(mf.Duration * 1000)
+ threshold := min(trackDurationMs*50/100, 240_000)
+ if params.PositionMs >= threshold {
+ err = p.incPlay(ctx, mf, now)
+ if err != nil {
+ log.Warn(ctx, "Error updating play counts", "id", mf.ID, "track", mf.Title, "user", user.UserName, err)
+ }
+ p.dispatchScrobble(ctx, mf, now)
+ }
+ }
+ p.playMap.Remove(clientId)
}
- // Calculate TTL based on remaining track duration. If position exceeds track duration,
- // remaining is set to 0 to avoid negative TTL.
- remaining := max(int(mf.Duration)-position, 0)
- // Add 5 seconds buffer to ensure the NowPlaying info is available slightly longer than the track duration.
- ttl := time.Duration(remaining+5) * time.Second
- _ = p.playMap.AddWithTTL(playerId, info, ttl)
- if conf.Server.EnableNowPlaying {
+ if conf.Server.EnableNowPlaying && p.playMap.Len() != prevCount {
p.broker.SendBroadcastMessage(ctx, &events.NowPlayingCount{Count: p.playMap.Len()})
}
- player, _ := request.PlayerFrom(ctx)
- if player.ScrobbleEnabled {
- p.enqueueNowPlaying(ctx, playerId, user.ID, mf, position)
+
+ if !params.IgnoreScrobble && player.ScrobbleEnabled &&
+ (params.State == StateStarting || params.State == StatePlaying) {
+ if info, err := p.playMap.Get(clientId); err == nil {
+ p.enqueueNowPlaying(ctx, clientId, user.ID, &info.MediaFile, int(params.PositionMs/1000))
+ }
}
+
return nil
}
@@ -296,9 +395,17 @@ func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, t *
func (p *playTracker) GetNowPlaying(_ context.Context) ([]NowPlayingInfo, error) {
res := p.playMap.Values()
- sort.Slice(res, func(i, j int) bool {
- return res[i].Start.After(res[j].Start)
+ slices.SortFunc(res, func(a, b NowPlayingInfo) int {
+ return b.Start.Compare(a.Start)
})
+ for i := range res {
+ if res[i].State == StatePlaying {
+ elapsed := time.Since(res[i].LastReport).Milliseconds()
+ estimated := res[i].PositionMs + int64(float64(elapsed)*res[i].PlaybackRate)
+ trackDurationMs := int64(res[i].MediaFile.Duration * 1000)
+ res[i].PositionMs = min(estimated, trackDurationMs)
+ }
+ }
return res, nil
}
diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go
index f7edecdf..64b3d3ee 100644
--- a/core/scrobbler/play_tracker_test.go
+++ b/core/scrobbler/play_tracker_test.go
@@ -20,9 +20,6 @@ import (
. "github.com/onsi/gomega"
)
-// mockPluginLoader is a test implementation of PluginLoader for plugin scrobbler tests
-// Moved to top-level scope to avoid linter issues
-
type mockPluginLoader struct {
mu sync.RWMutex
names []string
@@ -107,91 +104,21 @@ var _ = Describe("PlayTracker", func() {
Expect(tracker.(*playTracker).builtinScrobblers).ToNot(HaveKey("disabled"))
})
- Describe("NowPlaying", func() {
- It("sends track to agent", func() {
- err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
- Expect(err).ToNot(HaveOccurred())
- Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
- Expect(fake.GetUserID()).To(Equal("u-1"))
- Expect(fake.GetTrack().ID).To(Equal("123"))
- Expect(fake.GetTrack().Participants).To(Equal(track.Participants))
- })
- It("does not send track to agent if user has not authorized", func() {
- fake.Authorized = false
-
- err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
-
- Expect(err).ToNot(HaveOccurred())
- Expect(fake.GetNowPlayingCalled()).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.NowPlaying(ctx, "player-1", "player-one", "123", 0)
-
- Expect(err).ToNot(HaveOccurred())
- Expect(fake.GetNowPlayingCalled()).To(BeFalse())
- })
- It("does not send track to agent if artist is unknown", func() {
- track.Artist = consts.UnknownArtist
-
- err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
-
- Expect(err).ToNot(HaveOccurred())
- Expect(fake.GetNowPlayingCalled()).To(BeFalse())
- })
-
- It("stores position when greater than zero", func() {
- pos := 42
- err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", pos)
- Expect(err).ToNot(HaveOccurred())
-
- Eventually(func() int { return fake.GetPosition() }).Should(Equal(pos))
-
- playing, err := tracker.GetNowPlaying(ctx)
- Expect(err).ToNot(HaveOccurred())
- Expect(playing).To(HaveLen(1))
- Expect(playing[0].Position).To(Equal(pos))
- })
-
- It("sends event with count", func() {
- err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
- Expect(err).ToNot(HaveOccurred())
- eventList := eventBroker.getEvents()
- Expect(eventList).ToNot(BeEmpty())
- evt, ok := eventList[0].(*events.NowPlayingCount)
- Expect(ok).To(BeTrue())
- Expect(evt.Count).To(Equal(1))
- })
-
- It("does not send event when disabled", func() {
- conf.Server.EnableNowPlaying = false
- err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
- Expect(err).ToNot(HaveOccurred())
- Expect(eventBroker.getEvents()).To(BeEmpty())
- })
-
- It("passes user to scrobbler via context (fix for issue #4787)", func() {
- ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "testuser"})
- ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true})
-
- err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
- Expect(err).ToNot(HaveOccurred())
- Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
- // Verify the username was passed through async dispatch via context
- Eventually(func() string { return fake.GetUsername() }).Should(Equal("testuser"))
- })
- })
-
Describe("GetNowPlaying", func() {
It("returns current playing music", func() {
track2 := track
track2.ID = "456"
_ = ds.MediaFile(ctx).Put(&track2)
- ctx = request.WithUser(GinkgoT().Context(), model.User{UserName: "user-1"})
- _ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
- ctx = request.WithUser(GinkgoT().Context(), model.User{UserName: "user-2"})
- _ = tracker.NowPlaying(ctx, "player-2", "player-two", "456", 0)
+ 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)
@@ -336,6 +263,379 @@ var _ = Describe("PlayTracker", func() {
})
})
+ Describe("ReportPlayback", func() {
+ const defaultClientId = "client-1"
+
+ BeforeEach(func() {
+ ctx = request.WithPlayer(ctx, model.Player{ID: "p1", ScrobbleEnabled: true})
+ })
+
+ It("creates entry on starting and removes on stopped", func() {
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+
+ playing, err := tracker.GetNowPlaying(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(playing).To(HaveLen(1))
+ Expect(playing[0].State).To(Equal("starting"))
+ Expect(playing[0].MediaFile.ID).To(Equal("123"))
+
+ err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 0, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
+ IgnoreScrobble: true,
+ })
+ Expect(err).ToNot(HaveOccurred())
+
+ playing, err = tracker.GetNowPlaying(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(playing).To(BeEmpty())
+ })
+
+ It("full lifecycle: starting -> playing -> paused -> playing -> stopped", func() {
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ playing, err := tracker.GetNowPlaying(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(playing).To(HaveLen(1))
+ Expect(playing[0].State).To(Equal("playing"))
+ Expect(playing[0].PositionMs).To(BeNumerically(">=", int64(10000)))
+
+ err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 30000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ playing, err = tracker.GetNowPlaying(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(playing[0].State).To(Equal("paused"))
+ Expect(playing[0].PositionMs).To(Equal(int64(30000)))
+
+ err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 30000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+
+ err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 100000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ playing, err = tracker.GetNowPlaying(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(playing).To(BeEmpty())
+ })
+
+ It("starting replaces existing entry for same player", func() {
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 50000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ playing, err := tracker.GetNowPlaying(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(playing).To(HaveLen(1))
+ Expect(playing[0].State).To(Equal("starting"))
+ Expect(playing[0].PositionMs).To(Equal(int64(0)))
+ })
+
+ It("multiple players have independent sessions", func() {
+ ctx1 := request.WithUser(ctx, model.User{ID: "u-1", UserName: "user1"})
+ ctx1 = request.WithPlayer(ctx1, model.Player{ID: "p1", ScrobbleEnabled: true})
+
+ ctx2 := request.WithUser(ctx, model.User{ID: "u-1", UserName: "user1"})
+ ctx2 = request.WithPlayer(ctx2, model.Player{ID: "p2", ScrobbleEnabled: true})
+
+ track2 := track
+ track2.ID = "456"
+ _ = ds.MediaFile(ctx).Put(&track2)
+
+ err := tracker.ReportPlayback(ctx1, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: "client-1",
+ })
+ Expect(err).ToNot(HaveOccurred())
+ err = tracker.ReportPlayback(ctx2, ReportPlaybackParams{
+ MediaId: "456", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: "client-2",
+ })
+ Expect(err).ToNot(HaveOccurred())
+ playing, err := tracker.GetNowPlaying(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(playing).To(HaveLen(2))
+ })
+
+ Describe("auto-scrobble", func() {
+ It("scrobbles on stopped when positionMs >= 50% of track", func() {
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(track.PlayCount).To(Equal(int64(1)))
+ Expect(album.PlayCount).To(Equal(int64(1)))
+ Expect(artist1.PlayCount).To(Equal(int64(1)))
+ })
+
+ It("scrobbles on stopped when positionMs >= 4 min for long tracks", func() {
+ longTrack := model.MediaFile{
+ ID: "long", Title: "Long Song", Album: "Album", AlbumID: "al-1",
+ Duration: 600,
+ Participants: map[model.Role]model.ParticipantList{
+ model.RoleArtist: []model.Participant{_p("ar-1", "Artist 1")},
+ },
+ }
+ _ = ds.MediaFile(ctx).Put(&longTrack)
+
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "long", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "long", PositionMs: 240000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(longTrack.PlayCount).To(Equal(int64(1)))
+ })
+
+ It("does NOT scrobble when positionMs below threshold", func() {
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 10000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(track.PlayCount).To(Equal(int64(0)))
+ })
+
+ It("does NOT scrobble when ignoreScrobble=true even if threshold met", func() {
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
+ IgnoreScrobble: true,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(track.PlayCount).To(Equal(int64(0)))
+ })
+
+ It("does NOT scrobble when player ScrobbleEnabled=false even if threshold met", func() {
+ ctx = request.WithPlayer(ctx, model.Player{ID: "p1", ScrobbleEnabled: false})
+
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(track.PlayCount).To(Equal(int64(0)))
+ })
+
+ It("scrobbles twice for two separate sessions of same song", func() {
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(track.PlayCount).To(Equal(int64(2)))
+ })
+
+ It("dispatches to external scrobblers on auto-scrobble", func() {
+ fake.ScrobbleCalled.Store(false)
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(fake.ScrobbleCalled.Load()).To(BeTrue())
+ })
+ })
+
+ Describe("position estimation", func() {
+ It("estimates position for playing state based on elapsed time", func() {
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ time.Sleep(50 * time.Millisecond)
+ playing, err := tracker.GetNowPlaying(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(playing).To(HaveLen(1))
+ Expect(playing[0].PositionMs).To(BeNumerically(">", int64(10000)))
+ })
+
+ It("does NOT estimate for paused", func() {
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 10000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ time.Sleep(50 * time.Millisecond)
+ playing, err := tracker.GetNowPlaying(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(playing).To(HaveLen(1))
+ Expect(playing[0].PositionMs).To(Equal(int64(10000)))
+ })
+
+ It("does NOT estimate for starting", func() {
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ time.Sleep(50 * time.Millisecond)
+ playing, err := tracker.GetNowPlaying(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(playing).To(HaveLen(1))
+ Expect(playing[0].PositionMs).To(Equal(int64(0)))
+ })
+
+ It("respects playbackRate", func() {
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 2.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ time.Sleep(100 * time.Millisecond)
+ playing, err := tracker.GetNowPlaying(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(playing).To(HaveLen(1))
+ // At 2x speed, 100ms real time = ~200ms playback time
+ Expect(playing[0].PositionMs).To(BeNumerically(">", int64(10100)))
+ })
+
+ It("caps estimated position at track duration", func() {
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 179990, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ time.Sleep(50 * time.Millisecond)
+ playing, err := tracker.GetNowPlaying(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(playing).To(HaveLen(1))
+ Expect(playing[0].PositionMs).To(Equal(int64(180000))) // track.Duration * 1000
+ })
+
+ })
+
+ Describe("resilience (no prior starting)", func() {
+ It("playing without prior starting creates entry with Start approx now - positionMs", func() {
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 30000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ playing, err := tracker.GetNowPlaying(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(playing).To(HaveLen(1))
+ Expect(playing[0].State).To(Equal("playing"))
+ expectedStart := time.Now().Add(-30 * time.Second)
+ Expect(playing[0].Start).To(BeTemporally("~", expectedStart, 2*time.Second))
+ })
+
+ It("paused without prior starting creates entry", func() {
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 30000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ playing, err := tracker.GetNowPlaying(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(playing).To(HaveLen(1))
+ Expect(playing[0].State).To(Equal("paused"))
+ })
+
+ It("stopped without prior starting auto-scrobbles if threshold met", func() {
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(track.PlayCount).To(Equal(int64(1)))
+ })
+
+ It("stopped without prior starting does NOT scrobble if below threshold", func() {
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 10000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(track.PlayCount).To(Equal(int64(0)))
+ })
+ })
+
+ Describe("external scrobbler dispatch", func() {
+ It("dispatches NowPlaying on starting", func() {
+ fake.nowPlayingCalled.Store(false)
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
+ })
+
+ It("dispatches NowPlaying on playing", func() {
+ fake.nowPlayingCalled.Store(false)
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
+ })
+
+ It("does NOT dispatch on paused", func() {
+ fake.nowPlayingCalled.Store(false)
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 10000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse())
+ })
+
+ It("does NOT dispatch when ignoreScrobble=true", func() {
+ fake.nowPlayingCalled.Store(false)
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
+ IgnoreScrobble: true,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse())
+ })
+
+ It("does NOT dispatch when ScrobbleEnabled=false", func() {
+ fake.nowPlayingCalled.Store(false)
+ ctx = request.WithPlayer(ctx, model.Player{ID: "p1", ScrobbleEnabled: false})
+ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
+ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse())
+ })
+ })
+ })
+
Describe("Plugin scrobbler logic", func() {
var pluginLoader *mockPluginLoader
var pluginFake *fakeScrobbler
@@ -354,27 +654,32 @@ var _ = Describe("PlayTracker", func() {
})
It("registers and uses plugin scrobbler for NowPlaying", func() {
- err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
+ 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() {
- // First call: plugin present
- _ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
+ _ = 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)
- // Remove plugin
pluginLoader.SetNames([]string{})
- _ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
- // Should not be called since plugin was removed
+ _ = 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.NowPlaying(ctx, "player-1", "player-one", "123", 0)
+ 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())
@@ -550,6 +855,24 @@ var _ = Describe("PlayTracker", func() {
})
})
+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
@@ -577,17 +900,6 @@ func (f *fakeScrobbler) GetTrack() *model.MediaFile {
return f.track.Load()
}
-func (f *fakeScrobbler) GetPosition() int {
- return int(f.position.Load())
-}
-
-func (f *fakeScrobbler) GetUsername() string {
- if p := f.username.Load(); p != nil {
- return *p
- }
- return ""
-}
-
func (f *fakeScrobbler) IsAuthorized(ctx context.Context, userId string) bool {
return f.Error == nil && f.Authorized
}
diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go
index 4b003167..7ce8de2e 100644
--- a/server/e2e/e2e_suite_test.go
+++ b/server/e2e/e2e_suite_test.go
@@ -390,29 +390,13 @@ func (n noopProvider) AlbumImage(context.Context, string) (*url.URL, error) {
return nil, model.ErrNotFound
}
-// noopPlayTracker implements scrobbler.PlayTracker
-type noopPlayTracker struct{}
-
-func (n noopPlayTracker) NowPlaying(context.Context, string, string, string, int) error {
- return nil
-}
-
-func (n noopPlayTracker) GetNowPlaying(context.Context) ([]scrobbler.NowPlayingInfo, error) {
- return nil, nil
-}
-
-func (n noopPlayTracker) Submit(context.Context, []scrobbler.Submission) error {
- return nil
-}
-
// Compile-time interface checks
var (
- _ artwork.Artwork = noopArtwork{}
- _ stream.MediaStreamer = &spyStreamer{}
- _ core.Archiver = noopArchiver{}
- _ external.Provider = noopProvider{}
- _ scrobbler.PlayTracker = noopPlayTracker{}
- _ ffmpeg.FFmpeg = noopFFmpeg{}
+ _ artwork.Artwork = noopArtwork{}
+ _ stream.MediaStreamer = &spyStreamer{}
+ _ core.Archiver = noopArchiver{}
+ _ external.Provider = noopProvider{}
+ _ ffmpeg.FFmpeg = noopFFmpeg{}
)
var _ = BeforeSuite(func() {
@@ -513,7 +497,7 @@ func setupTestDB() {
s,
events.NoopBroker(),
playlists.NewPlaylists(ds, core.NewImageUploadService()),
- noopPlayTracker{},
+ scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil),
core.NewShare(ds),
playback.PlaybackServer(nil),
metrics.NewNoopInstance(),
diff --git a/server/e2e/subsonic_media_annotation_test.go b/server/e2e/subsonic_media_annotation_test.go
index 0f7e9208..ec9b070d 100644
--- a/server/e2e/subsonic_media_annotation_test.go
+++ b/server/e2e/subsonic_media_annotation_test.go
@@ -157,4 +157,115 @@ var _ = Describe("Media Annotation Endpoints", Ordered, func() {
Expect(resp.Error).ToNot(BeNil())
})
})
+
+ Describe("ReportPlayback", Ordered, func() {
+ var songID string
+
+ BeforeAll(func() {
+ songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "title"})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(songs).ToNot(BeEmpty())
+ songID = songs[0].ID
+ })
+
+ It("returns error when required params are missing", func() {
+ resp := doReq("reportPlayback")
+ Expect(resp.Status).To(Equal(responses.StatusFailed))
+ })
+
+ It("returns error for invalid state", func() {
+ resp := doReq("reportPlayback",
+ "mediaId", songID,
+ "mediaType", "song",
+ "positionMs", "0",
+ "state", "invalid",
+ )
+ Expect(resp.Status).To(Equal(responses.StatusFailed))
+ })
+
+ It("starting report creates a getNowPlaying entry", func() {
+ resp := doReq("reportPlayback",
+ "mediaId", songID,
+ "mediaType", "song",
+ "positionMs", "0",
+ "state", "starting",
+ )
+ Expect(resp.Status).To(Equal(responses.StatusOK))
+
+ np := doReq("getNowPlaying")
+ Expect(np.Status).To(Equal(responses.StatusOK))
+ Expect(np.NowPlaying.Entry).To(HaveLen(1))
+ Expect(np.NowPlaying.Entry[0].Id).To(Equal(songID))
+ Expect(np.NowPlaying.Entry[0].State).To(Equal("starting"))
+ })
+
+ It("playing report updates getNowPlaying state and position", func() {
+ resp := doReq("reportPlayback",
+ "mediaId", songID,
+ "mediaType", "song",
+ "positionMs", "30000",
+ "state", "playing",
+ )
+ Expect(resp.Status).To(Equal(responses.StatusOK))
+
+ np := doReq("getNowPlaying")
+ Expect(np.NowPlaying.Entry).To(HaveLen(1))
+ Expect(np.NowPlaying.Entry[0].State).To(Equal("playing"))
+ Expect(np.NowPlaying.Entry[0].PositionMs).To(BeNumerically(">=", int64(30000)))
+ })
+
+ It("paused report freezes position in getNowPlaying", func() {
+ resp := doReq("reportPlayback",
+ "mediaId", songID,
+ "mediaType", "song",
+ "positionMs", "30000",
+ "state", "paused",
+ )
+ Expect(resp.Status).To(Equal(responses.StatusOK))
+
+ np := doReq("getNowPlaying")
+ Expect(np.NowPlaying.Entry).To(HaveLen(1))
+ Expect(np.NowPlaying.Entry[0].State).To(Equal("paused"))
+ Expect(np.NowPlaying.Entry[0].PositionMs).To(Equal(int64(30000)))
+ })
+
+ It("stopped report removes entry from getNowPlaying", func() {
+ resp := doReq("reportPlayback",
+ "mediaId", songID,
+ "mediaType", "song",
+ "positionMs", "90000",
+ "state", "stopped",
+ )
+ Expect(resp.Status).To(Equal(responses.StatusOK))
+
+ np := doReq("getNowPlaying")
+ Expect(np.NowPlaying.Entry).To(BeEmpty())
+ })
+
+ It("accepts mediaType=podcast without error", func() {
+ resp := doReq("reportPlayback",
+ "mediaId", songID,
+ "mediaType", "podcast",
+ "positionMs", "0",
+ "state", "starting",
+ )
+ Expect(resp.Status).To(Equal(responses.StatusOK))
+ })
+
+ It("accepts optional playbackRate and ignoreScrobble", func() {
+ resp := doReq("reportPlayback",
+ "mediaId", songID,
+ "mediaType", "song",
+ "positionMs", "5000",
+ "state", "playing",
+ "playbackRate", "1.5",
+ "ignoreScrobble", "true",
+ )
+ Expect(resp.Status).To(Equal(responses.StatusOK))
+
+ np := doReq("getNowPlaying")
+ Expect(np.NowPlaying.Entry).To(HaveLen(1))
+ Expect(np.NowPlaying.Entry[0].PlaybackRate).To(Equal(1.5))
+ })
+ })
})
diff --git a/server/e2e/subsonic_sonic_similarity_test.go b/server/e2e/subsonic_sonic_similarity_test.go
index 3a010bc6..40161470 100644
--- a/server/e2e/subsonic_sonic_similarity_test.go
+++ b/server/e2e/subsonic_sonic_similarity_test.go
@@ -14,6 +14,7 @@ import (
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/playlists"
+ "github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/sonic"
"github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/model"
@@ -42,7 +43,7 @@ func buildSonicRouter(provider sonic.Provider) *subsonic.Router {
nil, // scanner
events.NoopBroker(),
playlists.NewPlaylists(ds, core.NewImageUploadService()),
- noopPlayTracker{},
+ scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil),
core.NewShare(ds),
playback.PlaybackServer(nil),
metrics.NewNoopInstance(),
diff --git a/server/subsonic/album_lists.go b/server/subsonic/album_lists.go
index 56cf469c..275665b0 100644
--- a/server/subsonic/album_lists.go
+++ b/server/subsonic/album_lists.go
@@ -214,11 +214,14 @@ func (api *Router) GetNowPlaying(r *http.Request) (*responses.Subsonic, error) {
var i int32
response.NowPlaying.Entry = slice.Map(npInfo, func(np scrobbler.NowPlayingInfo) responses.NowPlayingEntry {
return responses.NowPlayingEntry{
- Child: childFromMediaFile(ctx, np.MediaFile),
- UserName: np.Username,
- MinutesAgo: int32(time.Since(np.Start).Minutes()),
- PlayerId: i + 1, // Fake numeric playerId, it does not seem to be used for anything
- PlayerName: np.PlayerName,
+ Child: childFromMediaFile(ctx, np.MediaFile),
+ UserName: np.Username,
+ MinutesAgo: int32(time.Since(np.Start).Minutes()),
+ PlayerId: i + 1, // Fake numeric playerId, it does not seem to be used for anything
+ PlayerName: np.PlayerName,
+ State: np.State,
+ PositionMs: np.PositionMs,
+ PlaybackRate: np.PlaybackRate,
}
})
return response, nil
diff --git a/server/subsonic/api.go b/server/subsonic/api.go
index 5b656526..0bbfcb83 100644
--- a/server/subsonic/api.go
+++ b/server/subsonic/api.go
@@ -144,6 +144,7 @@ func (api *Router) routes() http.Handler {
h(r, "star", api.Star)
h(r, "unstar", api.Unstar)
h(r, "scrobble", api.Scrobble)
+ h(r, "reportPlayback", api.ReportPlayback)
})
r.Group(func(r chi.Router) {
r.Use(getPlayer(api.players))
diff --git a/server/subsonic/media_annotation.go b/server/subsonic/media_annotation.go
index 39bc83fa..e8b0278c 100644
--- a/server/subsonic/media_annotation.go
+++ b/server/subsonic/media_annotation.go
@@ -3,6 +3,7 @@ package subsonic
import (
"context"
"fmt"
+ "math"
"net/http"
"time"
@@ -217,6 +218,73 @@ func (api *Router) scrobblerNowPlaying(ctx context.Context, trackId string, posi
}
log.Info(ctx, "Now Playing", "title", mf.Title, "artist", mf.Artist, "user", username, "player", player.Name, "position", position)
- err = api.scrobbler.NowPlaying(ctx, clientId, client, trackId, position)
- return err
+ return api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{
+ MediaId: trackId,
+ PositionMs: int64(position) * 1000,
+ State: scrobbler.StatePlaying,
+ PlaybackRate: 1.0,
+ ClientId: clientId,
+ ClientName: client,
+ })
+}
+
+func (api *Router) ReportPlayback(r *http.Request) (*responses.Subsonic, error) {
+ p := req.Params(r)
+ mediaId, err := p.String("mediaId")
+ if err != nil {
+ return nil, err
+ }
+ mediaType, err := p.String("mediaType")
+ if err != nil {
+ return nil, err
+ }
+ positionMs, err := p.Int64("positionMs")
+ if err != nil {
+ return nil, err
+ }
+ if positionMs < 0 {
+ return nil, newError(responses.ErrorGeneric, "positionMs must be non-negative")
+ }
+ state, err := p.String("state")
+ if err != nil {
+ return nil, err
+ }
+
+ if !scrobbler.ValidStates[state] {
+ return nil, newError(responses.ErrorGeneric, "Invalid state: %s", state)
+ }
+
+ playbackRate := p.Float64Or("playbackRate", 1.0)
+ if math.IsNaN(playbackRate) || math.IsInf(playbackRate, 0) || playbackRate <= 0 {
+ return nil, newError(responses.ErrorGeneric, "playbackRate must be a finite positive number")
+ }
+ ignoreScrobble := p.BoolOr("ignoreScrobble", false)
+
+ ctx := r.Context()
+ if mediaType != "song" {
+ log.Warn(ctx, "reportPlayback received unsupported mediaType", "mediaType", mediaType, "mediaId", mediaId)
+ }
+
+ player, _ := request.PlayerFrom(ctx)
+ client, _ := request.ClientFrom(ctx)
+ clientId, ok := request.ClientUniqueIdFrom(ctx)
+ if !ok {
+ clientId = player.ID
+ }
+
+ err = api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{
+ MediaId: mediaId,
+ PositionMs: positionMs,
+ State: state,
+ PlaybackRate: playbackRate,
+ IgnoreScrobble: ignoreScrobble,
+ ClientId: clientId,
+ ClientName: client,
+ })
+ if err != nil {
+ log.Error(ctx, "Error in ReportPlayback", "mediaId", mediaId, "state", state, err)
+ return nil, err
+ }
+
+ return newResponse(), nil
}
diff --git a/server/subsonic/media_annotation_test.go b/server/subsonic/media_annotation_test.go
index 1c513edf..f5cf434e 100644
--- a/server/subsonic/media_annotation_test.go
+++ b/server/subsonic/media_annotation_test.go
@@ -10,6 +10,7 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/events"
+ "github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@@ -89,32 +90,107 @@ var _ = Describe("MediaAnnotationController", func() {
Expect(playTracker.Submissions).To(BeEmpty())
})
- It("registers a NowPlaying", func() {
+ It("registers a NowPlaying via ReportPlayback", func() {
_, err := router.Scrobble(req)
Expect(err).ToNot(HaveOccurred())
- Expect(playTracker.Playing).To(HaveLen(1))
- Expect(playTracker.Playing).To(HaveKey("player-1"))
+ Expect(playTracker.ReportedPlayback).To(HaveLen(1))
+ Expect(playTracker.ReportedPlayback[0].MediaId).To(Equal("12"))
+ Expect(playTracker.ReportedPlayback[0].State).To(Equal(scrobbler.StatePlaying))
+ Expect(playTracker.ReportedPlayback[0].ClientId).To(Equal("player-1"))
})
})
})
+
+ Describe("ReportPlayback", func() {
+ It("returns error when mediaId is missing", func() {
+ r := newGetRequest("mediaType=song", "positionMs=0", "state=playing")
+ _, err := router.ReportPlayback(r)
+ Expect(err).To(HaveOccurred())
+ })
+
+ It("returns error when mediaType is missing", func() {
+ r := newGetRequest("mediaId=123", "positionMs=0", "state=playing")
+ _, err := router.ReportPlayback(r)
+ Expect(err).To(HaveOccurred())
+ })
+
+ It("returns error when positionMs is missing", func() {
+ r := newGetRequest("mediaId=123", "mediaType=song", "state=playing")
+ _, err := router.ReportPlayback(r)
+ Expect(err).To(HaveOccurred())
+ })
+
+ It("returns error when state is missing", func() {
+ r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0")
+ _, err := router.ReportPlayback(r)
+ Expect(err).To(HaveOccurred())
+ })
+
+ It("returns error for invalid state value", func() {
+ r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0", "state=invalid")
+ _, err := router.ReportPlayback(r)
+ Expect(err).To(HaveOccurred())
+ })
+
+ It("returns error for negative positionMs", func() {
+ r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=-1", "state=playing")
+ _, err := router.ReportPlayback(r)
+ Expect(err).To(HaveOccurred())
+ })
+
+ It("returns error for NaN playbackRate", func() {
+ r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0", "state=playing", "playbackRate=NaN")
+ _, err := router.ReportPlayback(r)
+ Expect(err).To(HaveOccurred())
+ })
+
+ It("returns error for Inf playbackRate", func() {
+ r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0", "state=playing", "playbackRate=Inf")
+ _, err := router.ReportPlayback(r)
+ Expect(err).To(HaveOccurred())
+ })
+
+ It("returns error for negative playbackRate", func() {
+ r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0", "state=playing", "playbackRate=-1.0")
+ _, err := router.ReportPlayback(r)
+ Expect(err).To(HaveOccurred())
+ })
+
+ It("returns error for zero playbackRate", func() {
+ r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0", "state=playing", "playbackRate=0")
+ _, err := router.ReportPlayback(r)
+ Expect(err).To(HaveOccurred())
+ })
+
+ It("accepts mediaType=podcast without error", func() {
+ r := newGetRequest("mediaId=123", "mediaType=podcast", "positionMs=0", "state=playing")
+ ctx := request.WithPlayer(r.Context(), model.Player{ID: "p1"})
+ r = r.WithContext(ctx)
+ resp, err := router.ReportPlayback(r)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(resp.Status).To(Equal(responses.StatusOK))
+ })
+
+ It("defaults playbackRate to 1.0 and ignoreScrobble to false", func() {
+ r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=5000", "state=playing")
+ ctx := request.WithPlayer(r.Context(), model.Player{ID: "p1"})
+ r = r.WithContext(ctx)
+ _, err := router.ReportPlayback(r)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(playTracker.ReportedPlayback).To(HaveLen(1))
+ Expect(playTracker.ReportedPlayback[0].PlaybackRate).To(Equal(1.0))
+ Expect(playTracker.ReportedPlayback[0].IgnoreScrobble).To(BeFalse())
+ Expect(playTracker.ReportedPlayback[0].ClientId).To(Equal("p1"))
+ Expect(playTracker.ReportedPlayback[0].ClientName).To(BeEmpty())
+ })
+ })
})
type fakePlayTracker struct {
- Submissions []scrobbler.Submission
- Playing map[string]string
- Error error
-}
-
-func (f *fakePlayTracker) NowPlaying(_ context.Context, playerId string, _ string, trackId string, position int) error {
- if f.Error != nil {
- return f.Error
- }
- if f.Playing == nil {
- f.Playing = make(map[string]string)
- }
- f.Playing[playerId] = trackId
- return nil
+ Submissions []scrobbler.Submission
+ ReportedPlayback []scrobbler.ReportPlaybackParams
+ Error error
}
func (f *fakePlayTracker) GetNowPlaying(_ context.Context) ([]scrobbler.NowPlayingInfo, error) {
@@ -129,6 +205,14 @@ func (f *fakePlayTracker) Submit(_ context.Context, submissions []scrobbler.Subm
return nil
}
+func (f *fakePlayTracker) ReportPlayback(_ context.Context, params scrobbler.ReportPlaybackParams) error {
+ if f.Error != nil {
+ return f.Error
+ }
+ f.ReportedPlayback = append(f.ReportedPlayback, params)
+ return nil
+}
+
var _ scrobbler.PlayTracker = (*fakePlayTracker)(nil)
type fakeEventBroker struct {
diff --git a/server/subsonic/opensubsonic.go b/server/subsonic/opensubsonic.go
index 6c54d36d..85edb101 100644
--- a/server/subsonic/opensubsonic.go
+++ b/server/subsonic/opensubsonic.go
@@ -14,6 +14,7 @@ func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subson
{Name: "songLyrics", Versions: []int32{1}},
{Name: "indexBasedQueue", Versions: []int32{1}},
{Name: "transcoding", Versions: []int32{1}},
+ {Name: "playbackReport", Versions: []int32{1}},
}
if api.sonic != nil && api.sonic.HasProvider() {
extensions = append(extensions, responses.OpenSubsonicExtension{
diff --git a/server/subsonic/opensubsonic_test.go b/server/subsonic/opensubsonic_test.go
index d98599f8..3ccbf232 100644
--- a/server/subsonic/opensubsonic_test.go
+++ b/server/subsonic/opensubsonic_test.go
@@ -44,42 +44,13 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
})
- It("should return the base 5 OpenSubsonicExtensions without sonicSimilarity", func() {
+ It("should return the base 6 OpenSubsonicExtensions without sonicSimilarity", func() {
router.ServeHTTP(w, r)
// Make sure the endpoint is public, by not passing any authentication
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Header().Get("Content-Type")).To(Equal("application/json"))
- var response responses.JsonWrapper
- err := json.Unmarshal(w.Body.Bytes(), &response)
- Expect(err).NotTo(HaveOccurred())
- Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll(
- HaveLen(5),
- ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}),
- ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}),
- ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}),
- ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}),
- ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}),
- ))
- Expect(*response.Subsonic.OpenSubsonicExtensions).NotTo(
- ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}),
- )
- })
- })
-
- Context("with sonic similarity plugin", func() {
- BeforeEach(func() {
- sonicService := sonicsvc.New(nil, &mockSonicPluginLoader{names: []string{"test-plugin"}}, nil)
- router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, sonicService)
- })
-
- It("should return 6 extensions including sonicSimilarity", func() {
- router.ServeHTTP(w, r)
-
- Expect(w.Code).To(Equal(http.StatusOK))
- Expect(w.Header().Get("Content-Type")).To(Equal("application/json"))
-
var response responses.JsonWrapper
err := json.Unmarshal(w.Body.Bytes(), &response)
Expect(err).NotTo(HaveOccurred())
@@ -90,6 +61,37 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}),
+ ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}),
+ ))
+ Expect(*response.Subsonic.OpenSubsonicExtensions).NotTo(
+ ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}),
+ )
+ })
+ })
+
+ Context("with sonic similarity plugin", func() {
+ BeforeEach(func() {
+ sonicService := sonicsvc.New(nil, &mockSonicPluginLoader{names: []string{"test-plugin"}}, nil)
+ router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, sonicService)
+ })
+
+ It("should return 7 extensions including sonicSimilarity", func() {
+ router.ServeHTTP(w, r)
+
+ Expect(w.Code).To(Equal(http.StatusOK))
+ Expect(w.Header().Get("Content-Type")).To(Equal("application/json"))
+
+ var response responses.JsonWrapper
+ err := json.Unmarshal(w.Body.Bytes(), &response)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll(
+ HaveLen(7),
+ ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}),
+ ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}),
+ ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}),
+ ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}),
+ ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}),
+ ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}),
))
})
diff --git a/server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .JSON b/server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .JSON
new file mode 100644
index 00000000..f99cb62a
--- /dev/null
+++ b/server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .JSON
@@ -0,0 +1,23 @@
+{
+ "status": "ok",
+ "version": "1.16.1",
+ "type": "navidrome",
+ "serverVersion": "v0.55.0",
+ "openSubsonic": true,
+ "nowPlaying": {
+ "entry": [
+ {
+ "id": "1",
+ "isDir": false,
+ "title": "Song",
+ "username": "testuser",
+ "minutesAgo": 2,
+ "playerId": 1,
+ "playerName": "TestPlayer",
+ "state": "playing",
+ "positionMs": 120000,
+ "playbackRate": 1.5
+ }
+ ]
+ }
+}
diff --git a/server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .XML b/server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .XML
new file mode 100644
index 00000000..ae05b68d
--- /dev/null
+++ b/server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .XML
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .JSON b/server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .JSON
new file mode 100644
index 00000000..ab8f793f
--- /dev/null
+++ b/server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .JSON
@@ -0,0 +1,8 @@
+{
+ "status": "ok",
+ "version": "1.16.1",
+ "type": "navidrome",
+ "serverVersion": "v0.55.0",
+ "openSubsonic": true,
+ "nowPlaying": {}
+}
diff --git a/server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .XML b/server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .XML
new file mode 100644
index 00000000..763b4b58
--- /dev/null
+++ b/server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .XML
@@ -0,0 +1,3 @@
+
+
+
diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go
index c2e863b0..b0b2b875 100644
--- a/server/subsonic/responses/responses.go
+++ b/server/subsonic/responses/responses.go
@@ -358,10 +358,13 @@ type Starred2 struct {
type NowPlayingEntry struct {
Child
- UserName string `xml:"username,attr" json:"username"`
- MinutesAgo int32 `xml:"minutesAgo,attr" json:"minutesAgo"`
- PlayerId int32 `xml:"playerId,attr" json:"playerId"`
- PlayerName string `xml:"playerName,attr" json:"playerName,omitempty"`
+ UserName string `xml:"username,attr" json:"username"`
+ MinutesAgo int32 `xml:"minutesAgo,attr" json:"minutesAgo"`
+ PlayerId int32 `xml:"playerId,attr" json:"playerId"`
+ PlayerName string `xml:"playerName,attr" json:"playerName,omitempty"`
+ State string `xml:"state,attr" json:"state"`
+ PositionMs int64 `xml:"positionMs,attr" json:"positionMs"`
+ PlaybackRate float64 `xml:"playbackRate,attr" json:"playbackRate"`
}
type NowPlaying struct {
diff --git a/server/subsonic/responses/responses_test.go b/server/subsonic/responses/responses_test.go
index ba86deaf..ee98a3da 100644
--- a/server/subsonic/responses/responses_test.go
+++ b/server/subsonic/responses/responses_test.go
@@ -1109,6 +1109,42 @@ var _ = Describe("Responses", func() {
})
})
+ Describe("NowPlaying", func() {
+ BeforeEach(func() {
+ response.NowPlaying = &NowPlaying{}
+ })
+
+ Describe("without data", func() {
+ It("should match .XML", func() {
+ Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot())
+ })
+ It("should match .JSON", func() {
+ Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot())
+ })
+ })
+
+ Describe("with data", func() {
+ BeforeEach(func() {
+ response.NowPlaying.Entry = []NowPlayingEntry{{
+ Child: Child{Id: "1", Title: "Song", IsDir: false},
+ UserName: "testuser",
+ MinutesAgo: 2,
+ PlayerId: 1,
+ PlayerName: "TestPlayer",
+ State: "playing",
+ PositionMs: 120000,
+ PlaybackRate: 1.5,
+ }}
+ })
+ It("should match .XML", func() {
+ Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot())
+ })
+ It("should match .JSON", func() {
+ Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot())
+ })
+ })
+ })
+
Describe("SonicMatches", func() {
Context("without data", func() {
BeforeEach(func() {
diff --git a/utils/cache/simple_cache.go b/utils/cache/simple_cache.go
index cac41be7..2f0ff476 100644
--- a/utils/cache/simple_cache.go
+++ b/utils/cache/simple_cache.go
@@ -17,6 +17,7 @@ type SimpleCache[K comparable, V any] interface {
AddWithTTL(key K, value V, ttl time.Duration) error
Get(key K) (V, error)
GetWithLoader(key K, loader func(key K) (V, time.Duration, error)) (V, error)
+ Remove(key K)
Keys() []K
Values() []V
Len() int
@@ -77,6 +78,10 @@ func (c *simpleCache[K, V]) AddWithTTL(key K, value V, ttl time.Duration) error
return nil
}
+func (c *simpleCache[K, V]) Remove(key K) {
+ c.data.Delete(key)
+}
+
func (c *simpleCache[K, V]) Get(key K) (V, error) {
item := c.data.Get(key)
if item == nil {
diff --git a/utils/req/req.go b/utils/req/req.go
index f9fa5724..6b6135e1 100644
--- a/utils/req/req.go
+++ b/utils/req/req.go
@@ -170,3 +170,15 @@ func (r *Values) BoolOr(param string, def bool) bool {
}
return v
}
+
+func (r *Values) Float64Or(param string, def float64) float64 {
+ v, err := r.String(param)
+ if err != nil {
+ return def
+ }
+ f, err := strconv.ParseFloat(v, 64)
+ if err != nil {
+ return def
+ }
+ return f
+}
diff --git a/utils/req/req_test.go b/utils/req/req_test.go
index e710365b..d76b3b93 100644
--- a/utils/req/req_test.go
+++ b/utils/req/req_test.go
@@ -244,6 +244,23 @@ var _ = Describe("Request Helpers", func() {
})
})
+ Describe("Float64Or", func() {
+ It("returns parsed float value", func() {
+ r := req.Params(httptest.NewRequest("GET", "/test?rate=1.5", nil))
+ Expect(r.Float64Or("rate", 1.0)).To(Equal(1.5))
+ })
+
+ It("returns default when param is missing", func() {
+ r := req.Params(httptest.NewRequest("GET", "/test", nil))
+ Expect(r.Float64Or("rate", 1.0)).To(Equal(1.0))
+ })
+
+ It("returns default when param is not a valid float", func() {
+ r := req.Params(httptest.NewRequest("GET", "/test?rate=abc", nil))
+ Expect(r.Float64Or("rate", 1.0)).To(Equal(1.0))
+ })
+ })
+
Describe("ParamBoolPtr", func() {
Context("value is true", func() {
BeforeEach(func() {