diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index d6ffc44d4..2445473df 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -108,7 +108,7 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics) playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) - playbackServer := playback.GetInstance(dataStore) + playbackServer := playback.GetInstance(dataStore, playTracker) lyricsLyrics := lyrics.NewLyrics(dataStore, manager) transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg) sonicSonic := sonic.New(dataStore, manager, matcherMatcher) @@ -206,7 +206,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 fd08b340e..110998c92 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,12 @@ type playbackDevice struct { PlaybackDone chan bool ActiveTrack Track startTrackSwitcher sync.Once + playTracker scrobbler.PlayTracker + // 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. + scrobbleCtx context.Context } type DeviceStatus struct { @@ -61,7 +69,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 +79,8 @@ func NewPlaybackDevice(ctx context.Context, playbackServer PlaybackServer, name Gain: DefaultGain, PlaybackQueue: NewQueue(), PlaybackDone: make(chan bool), + playTracker: playTracker, + scrobbleCtx: ctx, } } @@ -102,6 +112,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 +135,7 @@ func (pd *playbackDevice) Start(ctx context.Context) (DeviceStatus, error) { return pd.getStatus(), err } pd.ActiveTrack.Unpause() + pd.reportActiveTrackStarted(ctx) } } @@ -140,6 +152,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 +161,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 +171,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 +215,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 +267,90 @@ 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.scrobbleCtx = merged + return merged +} + +// 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 + } + 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 +358,7 @@ func (pd *playbackDevice) trackSwitcherGoroutine() { case <-pd.PlaybackDone: log.Debug("Track switching detected") if pd.ActiveTrack != nil { + pd.reportActiveTrackFinished(pd.scrobbleCtx) pd.ActiveTrack.Close() pd.ActiveTrack = nil } @@ -271,6 +372,7 @@ func (pd *playbackDevice) trackSwitcherGoroutine() { } if pd.ActiveTrack != nil { pd.ActiveTrack.Unpause() + pd.reportActiveTrackStarted(pd.scrobbleCtx) } } 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 7dd02dcb1..3f20c68a1 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