* 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>
498 lines
15 KiB
Go
498 lines
15 KiB
Go
package scrobbler
|
|
|
|
import (
|
|
"context"
|
|
"maps"
|
|
"slices"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/consts"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/model/request"
|
|
"github.com/navidrome/navidrome/server/events"
|
|
"github.com/navidrome/navidrome/utils/cache"
|
|
"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
|
|
Username string
|
|
PlayerId string
|
|
PlayerName string
|
|
State string
|
|
PositionMs int64
|
|
PlaybackRate float64
|
|
LastReport time.Time
|
|
}
|
|
|
|
type Submission struct {
|
|
TrackID string
|
|
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
|
|
track *model.MediaFile
|
|
position int
|
|
}
|
|
|
|
type PlayTracker interface {
|
|
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
|
|
// (avoids import cycles)
|
|
type PluginLoader interface {
|
|
PluginNames(capability string) []string
|
|
LoadScrobbler(name string) (Scrobbler, bool)
|
|
}
|
|
|
|
type playTracker struct {
|
|
ds model.DataStore
|
|
broker events.Broker
|
|
playMap cache.SimpleCache[string, NowPlayingInfo]
|
|
builtinScrobblers map[string]Scrobbler
|
|
pluginScrobblers map[string]Scrobbler
|
|
pluginLoader PluginLoader
|
|
mu sync.RWMutex
|
|
npQueue map[string]nowPlayingEntry
|
|
npMu sync.Mutex
|
|
npSignal chan struct{}
|
|
shutdown chan struct{}
|
|
workerDone chan struct{}
|
|
}
|
|
|
|
func GetPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) PlayTracker {
|
|
return singleton.GetInstance(func() *playTracker {
|
|
return newPlayTracker(ds, broker, pluginManager)
|
|
})
|
|
}
|
|
|
|
// 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{
|
|
ds: ds,
|
|
playMap: m,
|
|
broker: broker,
|
|
builtinScrobblers: make(map[string]Scrobbler),
|
|
pluginScrobblers: make(map[string]Scrobbler),
|
|
pluginLoader: pluginManager,
|
|
npQueue: make(map[string]nowPlayingEntry),
|
|
npSignal: make(chan struct{}, 1),
|
|
shutdown: make(chan struct{}),
|
|
workerDone: make(chan struct{}),
|
|
}
|
|
if conf.Server.EnableNowPlaying {
|
|
m.OnExpiration(func(_ string, _ NowPlayingInfo) {
|
|
broker.SendBroadcastMessage(context.Background(), &events.NowPlayingCount{Count: m.Len()})
|
|
})
|
|
}
|
|
|
|
var enabled []string
|
|
for name, constructor := range constructors {
|
|
s := constructor(ds)
|
|
if s == nil {
|
|
log.Debug("Scrobbler not available. Missing configuration?", "name", name)
|
|
continue
|
|
}
|
|
enabled = append(enabled, name)
|
|
s = newBufferedScrobbler(ds, s, name)
|
|
p.builtinScrobblers[name] = s
|
|
}
|
|
log.Debug("List of builtin scrobblers enabled", "names", enabled)
|
|
go p.nowPlayingWorker()
|
|
return p
|
|
}
|
|
|
|
// stopNowPlayingWorker stops the background worker. This is primarily for testing.
|
|
func (p *playTracker) stopNowPlayingWorker() {
|
|
close(p.shutdown)
|
|
<-p.workerDone // Wait for worker to finish
|
|
}
|
|
|
|
// pluginNamesMatchScrobblers returns true if the set of pluginNames matches the keys in pluginScrobblers.
|
|
func pluginNamesMatchScrobblers(pluginNames []string, scrobblers map[string]Scrobbler) bool {
|
|
if len(pluginNames) != len(scrobblers) {
|
|
return false
|
|
}
|
|
for _, name := range pluginNames {
|
|
if _, ok := scrobblers[name]; !ok {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// refreshPluginScrobblers updates the pluginScrobblers map to match the current set of plugin scrobblers.
|
|
// The buffered scrobblers use a loader function to dynamically get the current plugin instance,
|
|
// so we only need to add/remove scrobblers when plugins are added/removed (not when reloaded).
|
|
func (p *playTracker) refreshPluginScrobblers() {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
if p.pluginLoader == nil {
|
|
return
|
|
}
|
|
|
|
// Get the list of available plugin names
|
|
pluginNames := p.pluginLoader.PluginNames("Scrobbler")
|
|
|
|
// Early return if plugin names match existing scrobblers (no change)
|
|
if pluginNamesMatchScrobblers(pluginNames, p.pluginScrobblers) {
|
|
return
|
|
}
|
|
|
|
// Build a set of current plugins for faster lookups
|
|
current := make(map[string]struct{}, len(pluginNames))
|
|
|
|
// Process additions - add new plugins with a loader that dynamically fetches the current instance
|
|
for _, name := range pluginNames {
|
|
current[name] = struct{}{}
|
|
if _, exists := p.pluginScrobblers[name]; !exists {
|
|
// Capture the name for the closure
|
|
pluginName := name
|
|
loader := p.pluginLoader
|
|
p.pluginScrobblers[name] = newBufferedScrobblerWithLoader(p.ds, name, func() (Scrobbler, bool) {
|
|
return loader.LoadScrobbler(pluginName)
|
|
})
|
|
}
|
|
}
|
|
|
|
type stoppableScrobbler interface {
|
|
Scrobbler
|
|
Stop()
|
|
}
|
|
|
|
// Process removals - remove plugins that no longer exist
|
|
for name, scrobbler := range p.pluginScrobblers {
|
|
if _, exists := current[name]; !exists {
|
|
// If the scrobbler implements stoppableScrobbler, call Stop() before removing it
|
|
if stoppable, ok := scrobbler.(stoppableScrobbler); ok {
|
|
log.Debug("Stopping scrobbler", "name", name)
|
|
stoppable.Stop()
|
|
}
|
|
delete(p.pluginScrobblers, name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// getActiveScrobblers refreshes plugin scrobblers, acquires a read lock,
|
|
// combines builtin and plugin scrobblers into a new map, releases the lock,
|
|
// and returns the combined map.
|
|
func (p *playTracker) getActiveScrobblers() map[string]Scrobbler {
|
|
p.refreshPluginScrobblers()
|
|
p.mu.RLock()
|
|
defer p.mu.RUnlock()
|
|
combined := maps.Clone(p.builtinScrobblers)
|
|
maps.Copy(combined, p.pluginScrobblers)
|
|
return combined
|
|
}
|
|
|
|
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 {
|
|
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)
|
|
}
|
|
|
|
if conf.Server.EnableNowPlaying && p.playMap.Len() != prevCount {
|
|
p.broker.SendBroadcastMessage(ctx, &events.NowPlayingCount{Count: p.playMap.Len()})
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func (p *playTracker) enqueueNowPlaying(ctx context.Context, playerId string, userId string, track *model.MediaFile, position int) {
|
|
p.npMu.Lock()
|
|
defer p.npMu.Unlock()
|
|
ctx = context.WithoutCancel(ctx) // Prevent cancellation from affecting background processing
|
|
p.npQueue[playerId] = nowPlayingEntry{
|
|
ctx: ctx,
|
|
userId: userId,
|
|
track: track,
|
|
position: position,
|
|
}
|
|
p.sendNowPlayingSignal()
|
|
}
|
|
|
|
func (p *playTracker) sendNowPlayingSignal() {
|
|
// Don't block if the previous signal was not read yet
|
|
select {
|
|
case p.npSignal <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
func (p *playTracker) nowPlayingWorker() {
|
|
defer close(p.workerDone)
|
|
for {
|
|
select {
|
|
case <-p.shutdown:
|
|
return
|
|
case <-time.After(time.Second):
|
|
case <-p.npSignal:
|
|
}
|
|
|
|
p.npMu.Lock()
|
|
if len(p.npQueue) == 0 {
|
|
p.npMu.Unlock()
|
|
continue
|
|
}
|
|
|
|
// Keep a copy of the entries to process and clear the queue
|
|
entries := p.npQueue
|
|
p.npQueue = make(map[string]nowPlayingEntry)
|
|
p.npMu.Unlock()
|
|
|
|
// Process entries without holding lock
|
|
for _, entry := range entries {
|
|
p.dispatchNowPlaying(entry.ctx, entry.userId, entry.track, entry.position)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, t *model.MediaFile, position int) {
|
|
if t.Artist == consts.UnknownArtist {
|
|
log.Debug(ctx, "Ignoring external NowPlaying update for track with unknown artist", "track", t.Title, "artist", t.Artist)
|
|
return
|
|
}
|
|
allScrobblers := p.getActiveScrobblers()
|
|
for name, s := range allScrobblers {
|
|
if !s.IsAuthorized(ctx, userId) {
|
|
continue
|
|
}
|
|
log.Debug(ctx, "Sending NowPlaying update", "scrobbler", name, "track", t.Title, "artist", t.Artist, "position", position)
|
|
err := s.NowPlaying(ctx, userId, t, position)
|
|
if err != nil {
|
|
log.Error(ctx, "Error sending NowPlayingInfo", "scrobbler", name, "track", t.Title, "artist", t.Artist, err)
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
func (p *playTracker) GetNowPlaying(_ context.Context) ([]NowPlayingInfo, error) {
|
|
res := p.playMap.Values()
|
|
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
|
|
}
|
|
|
|
func (p *playTracker) Submit(ctx context.Context, submissions []Submission) error {
|
|
username, _ := request.UsernameFrom(ctx)
|
|
player, _ := request.PlayerFrom(ctx)
|
|
if !player.ScrobbleEnabled {
|
|
log.Debug(ctx, "External scrobbling disabled for this player", "player", player.Name, "ip", player.IP, "user", username)
|
|
}
|
|
event := &events.RefreshResource{}
|
|
success := 0
|
|
|
|
for _, s := range submissions {
|
|
mf, err := p.ds.MediaFile(ctx).GetWithParticipants(s.TrackID)
|
|
if err != nil {
|
|
log.Error(ctx, "Cannot find track for scrobbling", "id", s.TrackID, "user", username, err)
|
|
continue
|
|
}
|
|
err = p.incPlay(ctx, mf, s.Timestamp)
|
|
if err != nil {
|
|
log.Error(ctx, "Error updating play counts", "id", mf.ID, "track", mf.Title, "user", username, err)
|
|
} else {
|
|
success++
|
|
event.With("song", mf.ID).With("album", mf.AlbumID).With("artist", mf.AlbumArtistID)
|
|
log.Info(ctx, "Scrobbled", "title", mf.Title, "artist", mf.Artist, "user", username, "timestamp", s.Timestamp)
|
|
if player.ScrobbleEnabled {
|
|
p.dispatchScrobble(ctx, mf, s.Timestamp)
|
|
}
|
|
}
|
|
}
|
|
|
|
if success > 0 {
|
|
p.broker.SendMessage(ctx, event)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *playTracker) incPlay(ctx context.Context, track *model.MediaFile, timestamp time.Time) error {
|
|
return p.ds.WithTx(func(tx model.DataStore) error {
|
|
err := tx.MediaFile(ctx).IncPlayCount(track.ID, timestamp)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = tx.Album(ctx).IncPlayCount(track.AlbumID, timestamp)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, artist := range track.Participants[model.RoleArtist] {
|
|
err = tx.Artist(ctx).IncPlayCount(artist.ID, timestamp)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if conf.Server.EnableScrobbleHistory {
|
|
return tx.Scrobble(ctx).RecordScrobble(track.ID, timestamp)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (p *playTracker) dispatchScrobble(ctx context.Context, t *model.MediaFile, playTime time.Time) {
|
|
if t.Artist == consts.UnknownArtist {
|
|
log.Debug(ctx, "Ignoring external Scrobble for track with unknown artist", "track", t.Title, "artist", t.Artist)
|
|
return
|
|
}
|
|
|
|
allScrobblers := p.getActiveScrobblers()
|
|
u, _ := request.UserFrom(ctx)
|
|
scrobble := Scrobble{MediaFile: *t, TimeStamp: playTime}
|
|
for name, s := range allScrobblers {
|
|
if !s.IsAuthorized(ctx, u.ID) {
|
|
continue
|
|
}
|
|
log.Debug(ctx, "Buffering Scrobble", "scrobbler", name, "track", t.Title, "artist", t.Artist)
|
|
err := s.Scrobble(ctx, u.ID, scrobble)
|
|
if err != nil {
|
|
log.Error(ctx, "Error sending Scrobble", "scrobbler", name, "track", t.Title, "artist", t.Artist, err)
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
var constructors map[string]Constructor
|
|
|
|
func Register(name string, init Constructor) {
|
|
if constructors == nil {
|
|
constructors = make(map[string]Constructor)
|
|
}
|
|
constructors[name] = init
|
|
}
|