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:
parent
2b9f326993
commit
94eb6c522b
20 changed files with 1002 additions and 216 deletions
|
|
@ -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
|
||||
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)
|
||||
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)
|
||||
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 {
|
||||
log.Error(ctx, "Error retrieving mediaFile", "id", trackId, err)
|
||||
return err
|
||||
}
|
||||
|
||||
user, _ := request.UserFrom(ctx)
|
||||
info := NowPlayingInfo{
|
||||
MediaFile: *mf,
|
||||
Start: time.Now(),
|
||||
Position: position,
|
||||
Start: now,
|
||||
Username: user.UserName,
|
||||
PlayerId: playerId,
|
||||
PlayerName: playerName,
|
||||
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)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -390,28 +390,12 @@ 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{}
|
||||
)
|
||||
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -219,6 +219,9 @@ func (api *Router) GetNowPlaying(r *http.Request) (*responses.Subsonic, error) {
|
|||
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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,34 +90,109 @@ 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
|
||||
ReportedPlayback []scrobbler.ReportPlaybackParams
|
||||
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) {
|
||||
return nil, f.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 {
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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}}),
|
||||
))
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"nowPlaying": {}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -362,6 +362,9 @@ type NowPlayingEntry struct {
|
|||
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 {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
5
utils/cache/simple_cache.go
vendored
5
utils/cache/simple_cache.go
vendored
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Reference in a new issue