feat(subsonic): implement playbackReport OpenSubsonic extension (#5442)

* feat(req): add Float64Or helper for parsing float query params

* feat(scrobbler): extend NowPlayingInfo with state/position/rate fields

* feat(scrobbler): implement ReportPlayback with state machine and auto-scrobble

* feat(responses): add state/positionMs/playbackRate to NowPlayingEntry

* feat(subsonic): add reportPlayback endpoint handler

* feat(subsonic): include state/positionMs/playbackRate in getNowPlaying response

* feat(subsonic): register playbackReport OpenSubsonic extension

* test(e2e): add reportPlayback endpoint e2e tests

* refactor(scrobbler): simplify ReportPlayback — extract helpers, remove duplication

- Add state constants and exported ValidStates map
- Extract remainingTTL() helper (was duplicated 3x)
- Merge playing/paused switch cases into single branch
- Use Get instead of GetWithParticipants for non-stopped states
- Guard NowPlayingCount broadcast with count-change detection
- Use cache entry for NowPlaying dispatch instead of extra DB query
- Remove redundant Position field from NowPlayingInfo

* refactor(scrobbler): skip DB query in playing/paused when playMap has entry

* fix(play_tracker): handle errors when adding/updating NowPlayingInfo in cache

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

* refactor(play_tracker): replace sort with slices.SortFunc for NowPlayingInfo

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

* fix(play_tracker): check all ReportPlayback errors in tests

Replace _ = with explicit error assertions to avoid masking
failures in intermediate calls.

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

* test(e2e): use real PlayTracker and assert getNowPlaying after reportPlayback

Replace noopPlayTracker with a real PlayTracker backed by the E2E
database. E2E tests now verify the full round-trip: reportPlayback
creates/updates/removes entries visible via getNowPlaying, including
state, positionMs, and playbackRate fields.

Export NewPlayTracker constructor for use outside the scrobbler package.

* fix(play_tracker): account for playback rate in TTL and detect track switches

The remainingTTL function now divides remaining time by the playback rate,
so cache entries expire correctly at non-1x speeds (e.g., 2x playback halves
the TTL). Zero/negative rates default to 1.0. The playing/paused case now
checks if the cached MediaFile ID matches the reported mediaId, falling back
to a DB fetch when the client switches tracks without sending stopped/starting.
Adds parameterized tests for remainingTTL covering rate variations and edge cases.

* fix(subsonic): validate positionMs and playbackRate in reportPlayback

Reject negative positionMs values and invalid playbackRate values (NaN,
Inf, zero, negative) at the API boundary before they reach TTL and
position estimation math. Returns clear error messages for each case.

* feat(play_tracker): add ClientId and ClientName to ReportPlayback parameters

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

* refactor(play_tracker): replace NowPlaying method with ReportPlayback calls

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

* refactor(play_tracker_test): remove redundant TTL behavior tests and clean up mockPluginLoader

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan Quintão 2026-04-30 23:04:05 -04:00 committed by GitHub
parent 2b9f326993
commit 94eb6c522b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1002 additions and 216 deletions

View file

@ -3,7 +3,7 @@ package scrobbler
import ( import (
"context" "context"
"maps" "maps"
"sort" "slices"
"sync" "sync"
"time" "time"
@ -17,13 +17,30 @@ import (
"github.com/navidrome/navidrome/utils/singleton" "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 { type NowPlayingInfo struct {
MediaFile model.MediaFile MediaFile model.MediaFile
Start time.Time Start time.Time
Position int Username string
Username string PlayerId string
PlayerId string PlayerName string
PlayerName string State string
PositionMs int64
PlaybackRate float64
LastReport time.Time
} }
type Submission struct { type Submission struct {
@ -31,6 +48,16 @@ type Submission struct {
Timestamp time.Time Timestamp time.Time
} }
type ReportPlaybackParams struct {
MediaId string
PositionMs int64
State string
PlaybackRate float64
IgnoreScrobble bool
ClientId string
ClientName string
}
type nowPlayingEntry struct { type nowPlayingEntry struct {
ctx context.Context ctx context.Context
userId string userId string
@ -39,9 +66,9 @@ type nowPlayingEntry struct {
} }
type PlayTracker interface { type PlayTracker interface {
NowPlaying(ctx context.Context, playerId string, playerName string, trackId string, position int) error
GetNowPlaying(ctx context.Context) ([]NowPlayingInfo, error) GetNowPlaying(ctx context.Context) ([]NowPlayingInfo, error)
Submit(ctx context.Context, submissions []Submission) 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 // 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 // NewPlayTracker creates a new PlayTracker instance. For normal usage, the PlayTracker has to be a singleton,
// the GetPlayTracker function above // 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 { func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) *playTracker {
m := cache.NewSimpleCache[string, NowPlayingInfo]() m := cache.NewSimpleCache[string, NowPlayingInfo]()
p := &playTracker{ p := &playTracker{
@ -193,36 +224,104 @@ func (p *playTracker) getActiveScrobblers() map[string]Scrobbler {
return combined return combined
} }
func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerName string, trackId string, position int) error { func remainingTTL(durationSec float32, positionMs int64, rate float64) time.Duration {
mf, err := p.ds.MediaFile(ctx).GetWithParticipants(trackId) if rate <= 0 {
if err != nil { rate = 1.0
log.Error(ctx, "Error retrieving mediaFile", "id", trackId, err)
return err
} }
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) user, _ := request.UserFrom(ctx)
info := NowPlayingInfo{ clientId := params.ClientId
MediaFile: *mf, client := params.ClientName
Start: time.Now(),
Position: position, now := time.Now()
Username: user.UserName, prevCount := p.playMap.Len()
PlayerId: playerId,
PlayerName: playerName, 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, if conf.Server.EnableNowPlaying && p.playMap.Len() != prevCount {
// 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 {
p.broker.SendBroadcastMessage(ctx, &events.NowPlayingCount{Count: p.playMap.Len()}) p.broker.SendBroadcastMessage(ctx, &events.NowPlayingCount{Count: p.playMap.Len()})
} }
player, _ := request.PlayerFrom(ctx)
if player.ScrobbleEnabled { if !params.IgnoreScrobble && player.ScrobbleEnabled &&
p.enqueueNowPlaying(ctx, playerId, user.ID, mf, position) (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 return nil
} }
@ -296,9 +395,17 @@ func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, t *
func (p *playTracker) GetNowPlaying(_ context.Context) ([]NowPlayingInfo, error) { func (p *playTracker) GetNowPlaying(_ context.Context) ([]NowPlayingInfo, error) {
res := p.playMap.Values() res := p.playMap.Values()
sort.Slice(res, func(i, j int) bool { slices.SortFunc(res, func(a, b NowPlayingInfo) int {
return res[i].Start.After(res[j].Start) 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 return res, nil
} }

View file

@ -20,9 +20,6 @@ import (
. "github.com/onsi/gomega" . "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 { type mockPluginLoader struct {
mu sync.RWMutex mu sync.RWMutex
names []string names []string
@ -107,91 +104,21 @@ var _ = Describe("PlayTracker", func() {
Expect(tracker.(*playTracker).builtinScrobblers).ToNot(HaveKey("disabled")) 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() { Describe("GetNowPlaying", func() {
It("returns current playing music", func() { It("returns current playing music", func() {
track2 := track track2 := track
track2.ID = "456" track2.ID = "456"
_ = ds.MediaFile(ctx).Put(&track2) _ = ds.MediaFile(ctx).Put(&track2)
ctx = request.WithUser(GinkgoT().Context(), model.User{UserName: "user-1"}) ctx1 := request.WithUser(GinkgoT().Context(), model.User{UserName: "user-1"})
_ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) ctx1 = request.WithPlayer(ctx1, model.Player{ScrobbleEnabled: true})
ctx = request.WithUser(GinkgoT().Context(), model.User{UserName: "user-2"}) _ = tracker.ReportPlayback(ctx1, ReportPlaybackParams{
_ = tracker.NowPlaying(ctx, "player-2", "player-two", "456", 0) 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) 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() { Describe("Plugin scrobbler logic", func() {
var pluginLoader *mockPluginLoader var pluginLoader *mockPluginLoader
var pluginFake *fakeScrobbler var pluginFake *fakeScrobbler
@ -354,27 +654,32 @@ var _ = Describe("PlayTracker", func() {
}) })
It("registers and uses plugin scrobbler for NowPlaying", 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()) Expect(err).ToNot(HaveOccurred())
Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue()) Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue())
}) })
It("removes plugin scrobbler if not present anymore", func() { It("removes plugin scrobbler if not present anymore", func() {
// First call: plugin present _ = tracker.ReportPlayback(ctx, ReportPlaybackParams{
_ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1",
})
Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue()) Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue())
pluginFake.nowPlayingCalled.Store(false) pluginFake.nowPlayingCalled.Store(false)
// Remove plugin
pluginLoader.SetNames([]string{}) pluginLoader.SetNames([]string{})
_ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) _ = tracker.ReportPlayback(ctx, ReportPlaybackParams{
// Should not be called since plugin was removed MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1",
})
Consistently(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeFalse()) Consistently(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeFalse())
}) })
It("calls both builtin and plugin scrobblers for NowPlaying", func() { It("calls both builtin and plugin scrobblers for NowPlaying", func() {
fake.nowPlayingCalled.Store(false) fake.nowPlayingCalled.Store(false)
pluginFake.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()) Expect(err).ToNot(HaveOccurred())
Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue()) Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
Eventually(func() bool { return pluginFake.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 { type fakeScrobbler struct {
Authorized bool Authorized bool
nowPlayingCalled atomic.Bool nowPlayingCalled atomic.Bool
@ -577,17 +900,6 @@ func (f *fakeScrobbler) GetTrack() *model.MediaFile {
return f.track.Load() 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 { func (f *fakeScrobbler) IsAuthorized(ctx context.Context, userId string) bool {
return f.Error == nil && f.Authorized return f.Error == nil && f.Authorized
} }

View file

@ -390,29 +390,13 @@ func (n noopProvider) AlbumImage(context.Context, string) (*url.URL, error) {
return nil, model.ErrNotFound 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 // Compile-time interface checks
var ( var (
_ artwork.Artwork = noopArtwork{} _ artwork.Artwork = noopArtwork{}
_ stream.MediaStreamer = &spyStreamer{} _ stream.MediaStreamer = &spyStreamer{}
_ core.Archiver = noopArchiver{} _ core.Archiver = noopArchiver{}
_ external.Provider = noopProvider{} _ external.Provider = noopProvider{}
_ scrobbler.PlayTracker = noopPlayTracker{} _ ffmpeg.FFmpeg = noopFFmpeg{}
_ ffmpeg.FFmpeg = noopFFmpeg{}
) )
var _ = BeforeSuite(func() { var _ = BeforeSuite(func() {
@ -513,7 +497,7 @@ func setupTestDB() {
s, s,
events.NoopBroker(), events.NoopBroker(),
playlists.NewPlaylists(ds, core.NewImageUploadService()), playlists.NewPlaylists(ds, core.NewImageUploadService()),
noopPlayTracker{}, scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil),
core.NewShare(ds), core.NewShare(ds),
playback.PlaybackServer(nil), playback.PlaybackServer(nil),
metrics.NewNoopInstance(), metrics.NewNoopInstance(),

View file

@ -157,4 +157,115 @@ var _ = Describe("Media Annotation Endpoints", Ordered, func() {
Expect(resp.Error).ToNot(BeNil()) 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))
})
})
}) })

View file

@ -14,6 +14,7 @@ import (
"github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/core/sonic"
"github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
@ -42,7 +43,7 @@ func buildSonicRouter(provider sonic.Provider) *subsonic.Router {
nil, // scanner nil, // scanner
events.NoopBroker(), events.NoopBroker(),
playlists.NewPlaylists(ds, core.NewImageUploadService()), playlists.NewPlaylists(ds, core.NewImageUploadService()),
noopPlayTracker{}, scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil),
core.NewShare(ds), core.NewShare(ds),
playback.PlaybackServer(nil), playback.PlaybackServer(nil),
metrics.NewNoopInstance(), metrics.NewNoopInstance(),

View file

@ -214,11 +214,14 @@ func (api *Router) GetNowPlaying(r *http.Request) (*responses.Subsonic, error) {
var i int32 var i int32
response.NowPlaying.Entry = slice.Map(npInfo, func(np scrobbler.NowPlayingInfo) responses.NowPlayingEntry { response.NowPlaying.Entry = slice.Map(npInfo, func(np scrobbler.NowPlayingInfo) responses.NowPlayingEntry {
return responses.NowPlayingEntry{ return responses.NowPlayingEntry{
Child: childFromMediaFile(ctx, np.MediaFile), Child: childFromMediaFile(ctx, np.MediaFile),
UserName: np.Username, UserName: np.Username,
MinutesAgo: int32(time.Since(np.Start).Minutes()), MinutesAgo: int32(time.Since(np.Start).Minutes()),
PlayerId: i + 1, // Fake numeric playerId, it does not seem to be used for anything PlayerId: i + 1, // Fake numeric playerId, it does not seem to be used for anything
PlayerName: np.PlayerName, PlayerName: np.PlayerName,
State: np.State,
PositionMs: np.PositionMs,
PlaybackRate: np.PlaybackRate,
} }
}) })
return response, nil return response, nil

View file

@ -144,6 +144,7 @@ func (api *Router) routes() http.Handler {
h(r, "star", api.Star) h(r, "star", api.Star)
h(r, "unstar", api.Unstar) h(r, "unstar", api.Unstar)
h(r, "scrobble", api.Scrobble) h(r, "scrobble", api.Scrobble)
h(r, "reportPlayback", api.ReportPlayback)
}) })
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(getPlayer(api.players)) r.Use(getPlayer(api.players))

View file

@ -3,6 +3,7 @@ package subsonic
import ( import (
"context" "context"
"fmt" "fmt"
"math"
"net/http" "net/http"
"time" "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) 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 api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{
return err 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
} }

View file

@ -10,6 +10,7 @@ import (
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/tests" "github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2" . "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
@ -89,32 +90,107 @@ var _ = Describe("MediaAnnotationController", func() {
Expect(playTracker.Submissions).To(BeEmpty()) Expect(playTracker.Submissions).To(BeEmpty())
}) })
It("registers a NowPlaying", func() { It("registers a NowPlaying via ReportPlayback", func() {
_, err := router.Scrobble(req) _, err := router.Scrobble(req)
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(playTracker.Playing).To(HaveLen(1)) Expect(playTracker.ReportedPlayback).To(HaveLen(1))
Expect(playTracker.Playing).To(HaveKey("player-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 { type fakePlayTracker struct {
Submissions []scrobbler.Submission Submissions []scrobbler.Submission
Playing map[string]string ReportedPlayback []scrobbler.ReportPlaybackParams
Error error 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
} }
func (f *fakePlayTracker) GetNowPlaying(_ context.Context) ([]scrobbler.NowPlayingInfo, 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 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) var _ scrobbler.PlayTracker = (*fakePlayTracker)(nil)
type fakeEventBroker struct { type fakeEventBroker struct {

View file

@ -14,6 +14,7 @@ func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subson
{Name: "songLyrics", Versions: []int32{1}}, {Name: "songLyrics", Versions: []int32{1}},
{Name: "indexBasedQueue", Versions: []int32{1}}, {Name: "indexBasedQueue", Versions: []int32{1}},
{Name: "transcoding", Versions: []int32{1}}, {Name: "transcoding", Versions: []int32{1}},
{Name: "playbackReport", Versions: []int32{1}},
} }
if api.sonic != nil && api.sonic.HasProvider() { if api.sonic != nil && api.sonic.HasProvider() {
extensions = append(extensions, responses.OpenSubsonicExtension{ extensions = append(extensions, responses.OpenSubsonicExtension{

View file

@ -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) 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) router.ServeHTTP(w, r)
// Make sure the endpoint is public, by not passing any authentication // Make sure the endpoint is public, by not passing any authentication
Expect(w.Code).To(Equal(http.StatusOK)) Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) 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 var response responses.JsonWrapper
err := json.Unmarshal(w.Body.Bytes(), &response) err := json.Unmarshal(w.Body.Bytes(), &response)
Expect(err).NotTo(HaveOccurred()) Expect(err).NotTo(HaveOccurred())
@ -90,6 +61,37 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", 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}}), ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}),
)) ))
}) })

View file

@ -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
}
]
}
}

View file

@ -0,0 +1,5 @@
<subsonic-response xmlns="http://subsonic.org/restapi" 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"></entry>
</nowPlaying>
</subsonic-response>

View file

@ -0,0 +1,8 @@
{
"status": "ok",
"version": "1.16.1",
"type": "navidrome",
"serverVersion": "v0.55.0",
"openSubsonic": true,
"nowPlaying": {}
}

View file

@ -0,0 +1,3 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<nowPlaying></nowPlaying>
</subsonic-response>

View file

@ -358,10 +358,13 @@ type Starred2 struct {
type NowPlayingEntry struct { type NowPlayingEntry struct {
Child Child
UserName string `xml:"username,attr" json:"username"` UserName string `xml:"username,attr" json:"username"`
MinutesAgo int32 `xml:"minutesAgo,attr" json:"minutesAgo"` MinutesAgo int32 `xml:"minutesAgo,attr" json:"minutesAgo"`
PlayerId int32 `xml:"playerId,attr" json:"playerId"` PlayerId int32 `xml:"playerId,attr" json:"playerId"`
PlayerName string `xml:"playerName,attr" json:"playerName,omitempty"` 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 { type NowPlaying struct {

View file

@ -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() { Describe("SonicMatches", func() {
Context("without data", func() { Context("without data", func() {
BeforeEach(func() { BeforeEach(func() {

View file

@ -17,6 +17,7 @@ type SimpleCache[K comparable, V any] interface {
AddWithTTL(key K, value V, ttl time.Duration) error AddWithTTL(key K, value V, ttl time.Duration) error
Get(key K) (V, error) Get(key K) (V, error)
GetWithLoader(key K, loader func(key K) (V, time.Duration, error)) (V, error) GetWithLoader(key K, loader func(key K) (V, time.Duration, error)) (V, error)
Remove(key K)
Keys() []K Keys() []K
Values() []V Values() []V
Len() int Len() int
@ -77,6 +78,10 @@ func (c *simpleCache[K, V]) AddWithTTL(key K, value V, ttl time.Duration) error
return nil return nil
} }
func (c *simpleCache[K, V]) Remove(key K) {
c.data.Delete(key)
}
func (c *simpleCache[K, V]) Get(key K) (V, error) { func (c *simpleCache[K, V]) Get(key K) (V, error) {
item := c.data.Get(key) item := c.data.Get(key)
if item == nil { if item == nil {

View file

@ -170,3 +170,15 @@ func (r *Values) BoolOr(param string, def bool) bool {
} }
return v 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
}

View file

@ -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() { Describe("ParamBoolPtr", func() {
Context("value is true", func() { Context("value is true", func() {
BeforeEach(func() { BeforeEach(func() {