quintodrome/core/scrobbler/play_tracker.go
Deluan Quintão 94eb6c522b
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>
2026-04-30 23:04:05 -04:00

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
}