package scrobbler
import (
"context"
"encoding/json"
"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/criteria"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/utils/cache"
"github.com/navidrome/navidrome/utils/singleton"
"github.com/navidrome/navidrome/utils/slice"
)
const (
StateStarting = "starting"
StatePlaying = "playing"
StatePaused = "paused"
StateStopped = "stopped"
StateExpired = "expired"
)
var ValidStates = map[string]bool{
StateStarting: true,
StatePlaying: true,
StatePaused: true,
StateStopped: true,
}
type PlaybackSession struct {
MediaFile model.MediaFile
Start time.Time
UserId string
Username string
PlayerId string
PlayerName string
State string
PositionMs int64
PlaybackRate float64
LastReport time.Time
filtered bool
}
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 playbackReportEntry struct {
ctx context.Context
info PlaybackSession
filtered bool
}
type PlayTracker interface {
GetNowPlaying(ctx context.Context) ([]PlaybackSession, error)
Submit(ctx context.Context, submissions []Submission) error
ReportPlayback(ctx context.Context, params ReportPlaybackParams) error
}
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, PlaybackSession]
sessionsMu sync.Mutex
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{}
prQueue []playbackReportEntry
prMu sync.Mutex
prSignal chan struct{}
prWorkerDone chan struct{}
}
func GetPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) PlayTracker {
return singleton.GetInstance(func() *playTracker {
return newPlayTracker(ds, broker, pluginManager)
})
}
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, PlaybackSession]()
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{}),
prSignal: make(chan struct{}, 1),
prWorkerDone: make(chan struct{}),
}
enableNowPlaying := conf.Server.EnableNowPlaying
m.OnExpiration(func(_ string, info PlaybackSession) {
log.Debug("PlaybackSession expired", "clientId", info.PlayerId, "mediaId", info.MediaFile.ID, "state",
info.State, "username", info.Username, "userId", info.UserId)
if enableNowPlaying {
broker.SendBroadcastMessage(context.Background(), &events.NowPlayingCount{Count: m.Len()})
}
ctx := request.WithUser(context.Background(), model.User{ID: info.UserId, UserName: info.Username})
if info.State != StateStopped {
log.Trace("Enqueueing PlaybackReport for expired session", "session", info)
info.State = StateExpired
info.LastReport = time.Now()
p.enqueuePlaybackReport(ctx, info, info.filtered)
}
})
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()
go p.playbackReportWorker()
return p
}
func (p *playTracker) stopBackgroundWorkers() {
close(p.shutdown)
<-p.workerDone
<-p.prWorkerDone
}
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
}
func (p *playTracker) refreshPluginScrobblers() {
p.mu.Lock()
defer p.mu.Unlock()
if p.pluginLoader == nil {
return
}
pluginNames := p.pluginLoader.PluginNames("Scrobbler")
if pluginNamesMatchScrobblers(pluginNames, p.pluginScrobblers) {
return
}
current := make(map[string]struct{}, len(pluginNames))
for _, name := range pluginNames {
current[name] = struct{}{}
if _, exists := p.pluginScrobblers[name]; !exists {
pluginName := name
loader := p.pluginLoader
p.pluginScrobblers[name] = newBufferedScrobblerWithLoader(p.ds, name, func() (Scrobbler, bool) {
return loader.LoadScrobbler(pluginName)
})
}
}
type stoppableScrobbler interface {
Scrobbler
Stop()
}
for name, scrobbler := range p.pluginScrobblers {
if _, exists := current[name]; !exists {
if stoppable, ok := scrobbler.(stoppableScrobbler); ok {
log.Debug("Stopping scrobbler", "name", name)
stoppable.Stop()
}
delete(p.pluginScrobblers, name)
}
}
}
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 (p *playTracker) hasPlayingSession(clientId, mediaId string) bool {
cur, err := p.playMap.Get(clientId)
return err == nil && cur.MediaFile.ID == mediaId && cur.State == StatePlaying
}
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()
var filtered bool
switch params.State {
case StateStarting:
if p.hasPlayingSession(clientId, params.MediaId) {
log.Trace(ctx, "Ignoring out-of-order starting report for playing session", "clientId", clientId, "mediaId", params.MediaId)
return nil
}
mf, err := p.ds.MediaFile().GetWithParticipants(ctx, params.MediaId)
if err != nil {
return err
}
filtered = p.isFilteredOut(ctx, mf)
info := PlaybackSession{
MediaFile: *mf,
filtered: filtered,
Start: now,
UserId: user.ID,
Username: user.UserName,
PlayerId: clientId,
PlayerName: client,
State: params.State,
PositionMs: params.PositionMs,
PlaybackRate: params.PlaybackRate,
LastReport: now,
}
p.sessionsMu.Lock()
if p.hasPlayingSession(clientId, params.MediaId) {
p.sessionsMu.Unlock()
log.Trace(ctx, "Ignoring out-of-order starting report for playing session", "clientId", clientId, "mediaId", params.MediaId)
return nil
}
err = p.playMap.AddWithTTL(clientId, info, remainingTTL(mf.Duration, params.PositionMs, params.PlaybackRate))
p.sessionsMu.Unlock()
if err != nil {
log.Warn(ctx, "Error adding PlaybackSession to cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err)
}
p.enqueuePlaybackReport(ctx, info, filtered)
case StatePlaying, StatePaused:
info, getErr := p.playMap.Get(clientId)
if getErr != nil || info.MediaFile.ID != params.MediaId {
mf, err := p.ds.MediaFile().GetWithParticipants(ctx, params.MediaId)
if err != nil {
return err
}
info = PlaybackSession{
MediaFile: *mf,
Start: now.Add(-time.Duration(params.PositionMs) * time.Millisecond),
UserId: user.ID,
Username: user.UserName,
PlayerId: clientId,
PlayerName: client,
}
}
info.State = params.State
info.PositionMs = params.PositionMs
info.PlaybackRate = params.PlaybackRate
info.LastReport = now
filtered = p.isFilteredOut(ctx, &info.MediaFile)
info.filtered = filtered
ttl := 30 * time.Minute
if params.State == StatePlaying {
ttl = remainingTTL(info.MediaFile.Duration, params.PositionMs, params.PlaybackRate)
}
log.Trace(ctx, "Updating PlaybackSession in cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, "positionMs", params.PositionMs, "playbackRate", params.PlaybackRate, "ttl", ttl)
p.sessionsMu.Lock()
err := p.playMap.AddWithTTL(clientId, info, ttl)
p.sessionsMu.Unlock()
if err != nil {
log.Warn(ctx, "Error updating PlaybackSession in cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err)
}
p.enqueuePlaybackReport(ctx, info, filtered)
case StateStopped:
var loadedMF *model.MediaFile
haveVerdict := false
if !params.IgnoreScrobble && player.ScrobbleEnabled {
mf, err := p.ds.MediaFile().GetWithParticipants(ctx, params.MediaId)
if err != nil {
return err
}
loadedMF = mf
filtered = p.isFilteredOut(ctx, mf)
haveVerdict = true
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, filtered)
}
}
p.sessionsMu.Lock()
info, getErr := p.playMap.Get(clientId)
if getErr == nil && info.MediaFile.ID != params.MediaId {
p.sessionsMu.Unlock()
log.Trace(ctx, "Ignoring out-of-order stopped report for different track", "clientId", clientId, "stoppedMediaId", params.MediaId, "currentMediaId", info.MediaFile.ID)
return nil
}
p.playMap.Remove(clientId)
p.sessionsMu.Unlock()
stoppedInfo := PlaybackSession{
UserId: user.ID,
Username: user.UserName,
PlayerId: clientId,
PlayerName: client,
State: params.State,
PositionMs: params.PositionMs,
PlaybackRate: params.PlaybackRate,
LastReport: now,
}
if getErr == nil {
stoppedInfo.MediaFile = info.MediaFile
stoppedInfo.Start = info.Start
} else {
mf := loadedMF
if mf == nil {
var mfErr error
mf, mfErr = p.ds.MediaFile().GetWithParticipants(ctx, params.MediaId)
if mfErr != nil {
return mfErr
}
}
stoppedInfo.MediaFile = *mf
}
if !haveVerdict {
filtered = p.isFilteredOut(ctx, &stoppedInfo.MediaFile)
}
p.enqueuePlaybackReport(ctx, stoppedInfo, filtered)
}
if conf.Server.EnableNowPlaying {
p.broker.SendBroadcastMessage(ctx, &events.NowPlayingCount{Count: p.playMap.Len()})
}
if player.ScrobbleEnabled &&
(params.State == StateStarting || params.State == StatePlaying) {
if filtered {
log.Debug(ctx, "Ignoring external NowPlaying update for filtered track", "mediaId", params.MediaId)
} else 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) GetNowPlaying(ctx context.Context) ([]PlaybackSession, error) {
res := p.playMap.Values()
if user, ok := request.UserFrom(ctx); ok {
res = slice.Filter(res, func(s PlaybackSession) bool {
return user.HasLibraryAccess(s.MediaFile.LibraryID)
})
}
slices.SortFunc(res, func(a, b PlaybackSession) 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().GetWithParticipants(ctx, s.TrackID)
if err != nil {
log.Error(ctx, "Cannot find track for scrobbling", "id", s.TrackID, "user", username, err)
continue
}
filtered := p.isFilteredOut(ctx, mf)
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, filtered)
}
}
}
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().IncPlayCount(ctx, track.ID, timestamp)
if err != nil {
return err
}
err = tx.Album().IncPlayCount(ctx, track.AlbumID, timestamp)
if err != nil {
return err
}
for _, artist := range track.Participants[model.RoleArtist] {
err = tx.Artist().IncPlayCount(ctx, artist.ID, timestamp)
if err != nil {
return err
}
}
if conf.Server.EnableScrobbleHistory {
return tx.Scrobble().RecordScrobble(ctx, track.ID, timestamp)
}
return nil
})
}
func (p *playTracker) isFilteredOut(ctx context.Context, t *model.MediaFile) bool {
u, _ := request.UserFrom(ctx)
if u.ScrobbleFilter == "" {
return false
}
var c criteria.Criteria
if err := json.Unmarshal([]byte(u.ScrobbleFilter), &c); err != nil {
log.Warn(ctx, "Invalid scrobble filter, ignoring", "user", u.UserName, err)
return false
}
match, err := p.ds.MediaFile().MatchesCriteria(ctx, t.ID, c)
if err != nil {
log.Warn(ctx, "Error evaluating scrobble filter, ignoring", "user", u.UserName, "track", t.Title, err)
return false
}
return match
}
func (p *playTracker) dispatchScrobble(ctx context.Context, t *model.MediaFile, playTime time.Time, filtered bool) {
if t.Artist == consts.UnknownArtist {
log.Debug(ctx, "Ignoring external Scrobble for track with unknown artist", "track", t.Title, "artist", t.Artist)
return
}
if filtered {
log.Debug(ctx, "Ignoring external Scrobble for filtered track", "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
}
func IsBuiltinScrobbler(name string) bool {
_, ok := constructors[name]
return ok
}