diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 2a396689d..53fea90d0 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -109,7 +109,7 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader) modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics) playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) - playbackServer := playback.GetInstance(dataStore) + playbackServer := playback.GetInstance(dataStore, playTracker) lyricsLyrics := lyrics.NewLyrics(dataStore, manager) sonicSonic := sonic.New(dataStore, manager, matcherMatcher) router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, sonicSonic) @@ -227,7 +227,11 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher { func GetPlaybackServer() playback.PlaybackServer { sqlDB := db.Db() dataStore := persistence.New(sqlDB) - playbackServer := playback.GetInstance(dataStore) + broker := events.GetBroker() + metricsMetrics := metrics.GetPrometheusInstance(dataStore) + manager := plugins.GetManager(dataStore, broker, metricsMetrics) + playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) + playbackServer := playback.GetInstance(dataStore, playTracker) return playbackServer } diff --git a/core/playback/device.go b/core/playback/device.go index 8e4e8880e..383c845f8 100644 --- a/core/playback/device.go +++ b/core/playback/device.go @@ -7,8 +7,10 @@ import ( "sync" "github.com/navidrome/navidrome/core/playback/mpv" + "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" ) type Track interface { @@ -34,6 +36,14 @@ type playbackDevice struct { PlaybackDone chan bool ActiveTrack Track startTrackSwitcher sync.Once + playTracker scrobbler.PlayTracker + scrobbleMu sync.RWMutex + // scrobbleCtx holds the most recently seen request context (merged onto + // serviceCtx so it survives past the originating HTTP request), used to + // report playback state from the async trackSwitcherGoroutine, which has + // no request of its own. Written from request-handling goroutines, read + // from trackSwitcherGoroutine, so access is guarded by scrobbleMu. + scrobbleCtx context.Context } type DeviceStatus struct { @@ -61,7 +71,7 @@ func (pd *playbackDevice) getStatus() DeviceStatus { // NewPlaybackDevice creates a new playback device which implements all the basic Jukebox mode commands defined here: // http://www.subsonic.org/pages/api.jsp#jukeboxControl // Starts the trackSwitcher goroutine for the device. -func NewPlaybackDevice(ctx context.Context, playbackServer PlaybackServer, name string, deviceName string) *playbackDevice { +func NewPlaybackDevice(ctx context.Context, playbackServer PlaybackServer, name string, deviceName string, playTracker scrobbler.PlayTracker) *playbackDevice { return &playbackDevice{ serviceCtx: ctx, ParentPlaybackServer: playbackServer, @@ -71,6 +81,8 @@ func NewPlaybackDevice(ctx context.Context, playbackServer PlaybackServer, name Gain: DefaultGain, PlaybackQueue: NewQueue(), PlaybackDone: make(chan bool), + playTracker: playTracker, + scrobbleCtx: ctx, } } @@ -102,6 +114,7 @@ func (pd *playbackDevice) Set(ctx context.Context, ids []string) (DeviceStatus, func (pd *playbackDevice) Start(ctx context.Context) (DeviceStatus, error) { log.Debug(ctx, "Processing Start action", "device", pd) + ctx = pd.rememberScrobbleCtx(ctx) pd.startTrackSwitcher.Do(func() { log.Info(ctx, "Starting trackSwitcher goroutine") @@ -124,6 +137,7 @@ func (pd *playbackDevice) Start(ctx context.Context) (DeviceStatus, error) { return pd.getStatus(), err } pd.ActiveTrack.Unpause() + pd.reportActiveTrackStarted(ctx) } } @@ -140,6 +154,7 @@ func (pd *playbackDevice) Stop(ctx context.Context) (DeviceStatus, error) { func (pd *playbackDevice) Skip(ctx context.Context, index int, offset int) (DeviceStatus, error) { log.Debug(ctx, "Processing Skip action", "index", index, "offset", offset, "device", pd) + ctx = pd.rememberScrobbleCtx(ctx) wasPlaying := pd.isPlaying() @@ -148,6 +163,7 @@ func (pd *playbackDevice) Skip(ctx context.Context, index int, offset int) (Devi } if index != pd.PlaybackQueue.Index && pd.ActiveTrack != nil { + pd.reportActiveTrackStopped(ctx) pd.ActiveTrack.Close() pd.ActiveTrack = nil } @@ -157,6 +173,7 @@ func (pd *playbackDevice) Skip(ctx context.Context, index int, offset int) (Devi if err != nil { return pd.getStatus(), err } + pd.reportActiveTrackStarted(ctx) } err := pd.ActiveTrack.SetPosition(offset) @@ -200,6 +217,7 @@ func (pd *playbackDevice) Add(ctx context.Context, ids []string) (DeviceStatus, func (pd *playbackDevice) Clear(ctx context.Context) (DeviceStatus, error) { log.Debug(ctx, "Processing Clear action", "device", pd) if pd.ActiveTrack != nil { + pd.reportActiveTrackStopped(pd.rememberScrobbleCtx(ctx)) pd.ActiveTrack.Pause() pd.ActiveTrack.Close() pd.ActiveTrack = nil @@ -251,6 +269,109 @@ func (pd *playbackDevice) isPlaying() bool { return pd.ActiveTrack != nil && pd.ActiveTrack.IsPlaying() } +// rememberScrobbleCtx merges request-scoped values (user, player, client) +// from ctx onto the device's long-lived service context, and remembers the +// result. This lets the async trackSwitcherGoroutine - which has no HTTP +// request of its own - still report playback state once the originating +// request has completed. +func (pd *playbackDevice) rememberScrobbleCtx(ctx context.Context) context.Context { + merged := request.AddValues(pd.serviceCtx, ctx) + pd.scrobbleMu.Lock() + pd.scrobbleCtx = merged + pd.scrobbleMu.Unlock() + return merged +} + +// getScrobbleCtx returns the most recently remembered scrobble context, +// safe to call concurrently with rememberScrobbleCtx. +func (pd *playbackDevice) getScrobbleCtx() context.Context { + pd.scrobbleMu.RLock() + defer pd.scrobbleMu.RUnlock() + return pd.scrobbleCtx +} + +// reportActiveTrackStarted reports to the scrobbler that the currently active +// track started playing. Must be called after switchActiveTrackByIndex. +func (pd *playbackDevice) reportActiveTrackStarted(ctx context.Context) { + if pd.ActiveTrack == nil { + return + } + mf := pd.PlaybackQueue.Current() + if mf == nil { + return + } + pd.reportPlayback(ctx, *mf, scrobbler.StateStarting, 0) +} + +// reportActiveTrackStopped reports to the scrobbler that the currently active +// track stopped at its current (live) position. Must be called before +// ActiveTrack is closed/replaced and before PlaybackQueue's index is advanced. +func (pd *playbackDevice) reportActiveTrackStopped(ctx context.Context) { + if pd.ActiveTrack == nil { + return + } + mf := pd.PlaybackQueue.Current() + if mf == nil { + return + } + pd.reportPlayback(ctx, *mf, scrobbler.StateStopped, int64(pd.ActiveTrack.Position())*1000) +} + +// reportActiveTrackFinished reports a track that played through to its +// natural end (mpv reached end-of-stream). By the time this fires, mpv has +// already exited, so a live position can't be queried; the full track +// duration is reported instead. Must be called before ActiveTrack is closed +// and before PlaybackQueue's index is advanced. +func (pd *playbackDevice) reportActiveTrackFinished(ctx context.Context) { + if pd.ActiveTrack == nil { + return + } + mf := pd.PlaybackQueue.Current() + if mf == nil { + return + } + pd.reportPlayback(ctx, *mf, scrobbler.StateStopped, int64(mf.Duration*1000)) +} + +// reportPlayback forwards a jukebox playback state transition to the +// scrobbler, the same way client-driven playback does via the reportPlayback +// Subsonic endpoint. Jukebox playback happens entirely server-side, so +// without this no play count or scrobble is ever recorded for it (#5693). +func (pd *playbackDevice) reportPlayback(ctx context.Context, mf model.MediaFile, state string, positionMs int64) { + if pd.playTracker == nil { + return + } + if ctx == nil { + ctx = pd.serviceCtx + } + player, _ := request.PlayerFrom(ctx) + client, _ := request.ClientFrom(ctx) + clientId, ok := request.ClientUniqueIdFrom(ctx) + if !ok { + clientId = player.ID + } + // Fall back to device-scoped identifiers so an empty clientId (e.g. no + // jukebox request has run yet) can't collide with another player's + // session key in the scrobbler's playMap cache. + if clientId == "" { + clientId = "jukebox-" + pd.Name + } + if client == "" { + client = "Jukebox" + } + err := pd.playTracker.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{ + MediaId: mf.ID, + PositionMs: positionMs, + State: state, + PlaybackRate: 1.0, + ClientId: clientId, + ClientName: client, + }) + if err != nil { + log.Warn(ctx, "Error reporting jukebox playback", "mediaId", mf.ID, "state", state, err) + } +} + func (pd *playbackDevice) trackSwitcherGoroutine() { log.Debug("Started trackSwitcher goroutine", "device", pd) for { @@ -258,6 +379,7 @@ func (pd *playbackDevice) trackSwitcherGoroutine() { case <-pd.PlaybackDone: log.Debug("Track switching detected") if pd.ActiveTrack != nil { + pd.reportActiveTrackFinished(pd.getScrobbleCtx()) pd.ActiveTrack.Close() pd.ActiveTrack = nil } @@ -271,6 +393,7 @@ func (pd *playbackDevice) trackSwitcherGoroutine() { } if pd.ActiveTrack != nil { pd.ActiveTrack.Unpause() + pd.reportActiveTrackStarted(pd.getScrobbleCtx()) } } else { log.Debug("There is no song left in the playlist. Finish.") diff --git a/core/playback/playbackserver.go b/core/playback/playbackserver.go index 48e3bcfaf..9df080d4d 100644 --- a/core/playback/playbackserver.go +++ b/core/playback/playbackserver.go @@ -10,6 +10,7 @@ import ( "fmt" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/singleton" @@ -24,13 +25,14 @@ type PlaybackServer interface { type playbackServer struct { ctx *context.Context datastore model.DataStore + playTracker scrobbler.PlayTracker playbackDevices []playbackDevice } // GetInstance returns the playback-server singleton -func GetInstance(ds model.DataStore) PlaybackServer { +func GetInstance(ds model.DataStore, playTracker scrobbler.PlayTracker) PlaybackServer { return singleton.GetInstance(func() *playbackServer { - return &playbackServer{datastore: ds} + return &playbackServer{datastore: ds, playTracker: playTracker} }) } @@ -62,7 +64,7 @@ func (ps *playbackServer) initDeviceStatus(ctx context.Context, devices []conf.A if defaultDevice == "" { // if there are no devices given and no default device, we create a synthetic device named "auto" if len(devices) == 0 { - pbDevices[0] = *NewPlaybackDevice(ctx, ps, "auto", "auto") + pbDevices[0] = *NewPlaybackDevice(ctx, ps, "auto", "auto", ps.playTracker) } // if there is but only one entry and no default given, just use that. @@ -70,7 +72,7 @@ func (ps *playbackServer) initDeviceStatus(ctx context.Context, devices []conf.A if len(devices[0]) != 2 { return []playbackDevice{}, fmt.Errorf("audio device definition ought to contain 2 fields, found: %d ", len(devices[0])) } - pbDevices[0] = *NewPlaybackDevice(ctx, ps, devices[0][0], devices[0][1]) + pbDevices[0] = *NewPlaybackDevice(ctx, ps, devices[0][0], devices[0][1], ps.playTracker) } if len(devices) > 1 { @@ -86,7 +88,7 @@ func (ps *playbackServer) initDeviceStatus(ctx context.Context, devices []conf.A return []playbackDevice{}, fmt.Errorf("audio device definition ought to contain 2 fields, found: %d ", len(audioDevice)) } - pbDevices[idx] = *NewPlaybackDevice(ctx, ps, audioDevice[0], audioDevice[1]) + pbDevices[idx] = *NewPlaybackDevice(ctx, ps, audioDevice[0], audioDevice[1], ps.playTracker) if audioDevice[0] == defaultDevice { pbDevices[idx].Default = true