From 2d864a5320ca1f13aa3d79b4267e5b663cbea41b Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Wed, 22 Apr 2026 11:00:51 -0700 Subject: [PATCH] add support for OpenSubsonic playbackReport extension (#910) * add support for OpenSubsonic playbackReport extension * better style --- backend/mediaprovider/mediaprovider.go | 4 +++ .../subsonic/subsonicmediaprovider.go | 27 +++++++++++++++++++ backend/playbackengine.go | 25 +++++++++++++++++ go.mod | 2 +- go.sum | 2 ++ 5 files changed, 59 insertions(+), 1 deletion(-) diff --git a/backend/mediaprovider/mediaprovider.go b/backend/mediaprovider/mediaprovider.go index 7769e58..b93a64d 100644 --- a/backend/mediaprovider/mediaprovider.go +++ b/backend/mediaprovider/mediaprovider.go @@ -302,6 +302,10 @@ type CanSavePlayQueue interface { GetPlayQueue() (*SavedPlayQueue, error) } +type CanReportPlayback interface { + ReportPlayback(trackID string, positionMs int64, state string) error +} + type LyricsProvider interface { GetLyrics(track *Track) (*Lyrics, error) } diff --git a/backend/mediaprovider/subsonic/subsonicmediaprovider.go b/backend/mediaprovider/subsonic/subsonicmediaprovider.go index 3d14b35..eb6588b 100644 --- a/backend/mediaprovider/subsonic/subsonicmediaprovider.go +++ b/backend/mediaprovider/subsonic/subsonicmediaprovider.go @@ -37,6 +37,9 @@ type subsonicMediaProvider struct { radiosCached []*mediaprovider.RadioStation radiosCachedAt int64 // unix + + playbackReportOnce sync.Once + playbackReportSupported bool } func SubsonicMediaProvider(subsonicClient *subsonic.Client) mediaprovider.MediaProvider { @@ -459,6 +462,30 @@ func (s *subsonicMediaProvider) GetLyrics(track *mediaprovider.Track) (*mediapro // CanSavePlayQueue interface var _ mediaprovider.CanSavePlayQueue = (*subsonicMediaProvider)(nil) +// CanReportPlayback interface +var _ mediaprovider.CanReportPlayback = (*subsonicMediaProvider)(nil) + +func (s *subsonicMediaProvider) ReportPlayback(trackID string, positionMs int64, state string) error { + s.playbackReportOnce.Do(func() { + ext, err := s.client.GetOpenSubsonicExtensions() + s.playbackReportSupported = err == nil && slices.ContainsFunc(ext, + func(e *subsonic.OpenSubsonicExtension) bool { + return e.Name == subsonic.PlaybackReport + }) + }) + if !s.playbackReportSupported { + return nil + } + ignoreScrobble := true + return s.client.ReportPlayback(subsonic.ReportPlaybackParameters{ + MediaID: trackID, + MediaType: subsonic.PlaybackMediaTypeSong, + PositionMs: positionMs, + State: subsonic.PlaybackState(state), + IgnoreScrobble: &ignoreScrobble, + }) +} + func (s *subsonicMediaProvider) SavePlayQueue(trackIDs []string, currentTrackIdx int, timeSeconds int) error { if len(trackIDs) == 0 { return nil // don't save an empty queue diff --git a/backend/playbackengine.go b/backend/playbackengine.go index a84022b..0a40f6a 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -170,17 +170,24 @@ func (p *playbackEngine) registerPlayerCallbacks(pl player.BasePlayer) { pl.OnSeek(func() { p.handleTimePosUpdate(true) p.invokeNoArgCallbacks(p.onSeek) + seekState := "playing" + if p.PlaybackStatus().State == player.Paused { + seekState = "paused" + } + p.reportPlayback(seekState) }) pl.OnStopped(p.handleOnStopped) pl.OnPaused(func() { p.playTimeStopwatch.Stop() p.stopPollTimePos() p.invokeNoArgCallbacks(p.onPaused) + p.reportPlayback("paused") }) pl.OnPlaying(func() { p.playTimeStopwatch.Start() p.startPollTimePos() p.invokeNoArgCallbacks(p.onPlaying) + p.reportPlayback("playing") }) } @@ -926,6 +933,7 @@ func (p *playbackEngine) handleOnStopped() { p.handleTimePosUpdate(false) p.invokeOnSongChangeCallbacks() p.invokeNoArgCallbacks(p.onStopped) + p.reportPlayback("stopped") p.alreadyScrobbled = false p.wasStopped = true p.nowPlayingIdx = -1 @@ -1091,6 +1099,7 @@ func (p *playbackEngine) sendNowPlayingScrobble() { track.PlayCount += 1 } go p.sm.Server.TrackBeganPlayback(track.ID) + p.reportPlayback("starting") } // creates a deep copy of the track info so that we can maintain our own state @@ -1113,6 +1122,22 @@ func (p *playbackEngine) invokeOnSongChangeCallbacks() { p.lastScrobbled = nil } +func (p *playbackEngine) reportPlayback(state string) { + reporter, ok := p.sm.Server.(mediaprovider.CanReportPlayback) + if !ok { + return + } + if p.getPlayQueueLength() == 0 || p.nowPlayingIdx < 0 { + return + } + np := p.NowPlaying() + if np == nil || np.Metadata().Type != mediaprovider.MediaItemTypeTrack { + return + } + posMs := int64(p.latestTrackPosition * 1000) + go reporter.ReportPlayback(np.Metadata().ID, posMs, state) +} + func (pm *playbackEngine) invokeNoArgCallbacks(cbs []func()) { if pm.callbacksDisabled { return diff --git a/go.mod b/go.mod index 70929d6..becb0ea 100644 --- a/go.mod +++ b/go.mod @@ -22,7 +22,7 @@ require ( github.com/quarckster/go-mpris-server v1.0.3 github.com/supersonic-app/fyne-lyrics v0.0.0-20250614151306-b1880a70a410 github.com/supersonic-app/go-mpv v0.1.1-0.20250822102843-7a8cde5f5449 - github.com/supersonic-app/go-subsonic v0.0.0-20260409151555-0d5fb7d4801a + github.com/supersonic-app/go-subsonic v0.0.0-20260416152144-7a5f505a273c github.com/supersonic-app/go-upnpcast v0.1.0 github.com/zalando/go-keyring v0.2.8 golang.org/x/net v0.50.0 diff --git a/go.sum b/go.sum index c6cc264..24a3ffb 100644 --- a/go.sum +++ b/go.sum @@ -125,6 +125,8 @@ github.com/supersonic-app/go-mpv v0.1.1-0.20250822102843-7a8cde5f5449 h1:UHIPI43 github.com/supersonic-app/go-mpv v0.1.1-0.20250822102843-7a8cde5f5449/go.mod h1:1bQz6kBQumJopXEbkiqoLxIXLy7F7yWFBvknvpAtIC0= github.com/supersonic-app/go-subsonic v0.0.0-20260409151555-0d5fb7d4801a h1:zK5ZbE7H337udw2tOZQlnhGv7URSxfV1AIvRpKdHG2E= github.com/supersonic-app/go-subsonic v0.0.0-20260409151555-0d5fb7d4801a/go.mod h1:ClhAgC2qobwH19D6kdwULLYSHlOmldDGkdu2G2Z0Ijo= +github.com/supersonic-app/go-subsonic v0.0.0-20260416152144-7a5f505a273c h1:wms3FvahSfcnacW2rs9L+vzJR8FmTE5xTrRgf3+k3eQ= +github.com/supersonic-app/go-subsonic v0.0.0-20260416152144-7a5f505a273c/go.mod h1:ClhAgC2qobwH19D6kdwULLYSHlOmldDGkdu2G2Z0Ijo= github.com/supersonic-app/go-upnpcast v0.1.0 h1:SL0WVgnqvAVaLT9MSRhSeVwF9pcUw1cgBITKVoWXN3g= github.com/supersonic-app/go-upnpcast v0.1.0/go.mod h1:o/QGDTK3qdaS3h9Bt8aH5ywgbWbrPVzPCHlZYFs0IHY= github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=