add scrobbling for Jellyfin
This commit is contained in:
@@ -1,7 +1,6 @@
|
|||||||
package jellyfin
|
package jellyfin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"image"
|
"image"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
@@ -360,8 +359,14 @@ func (j *jellyfinMediaProvider) DownloadTrack(trackID string) (io.Reader, error)
|
|||||||
return resp.Body, nil
|
return resp.Body, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *jellyfinMediaProvider) Scrobble(trackID string, submission bool) error {
|
func (j *jellyfinMediaProvider) ClientDecidesScrobble() bool { return false }
|
||||||
return errors.New("unimplemented")
|
|
||||||
|
func (j *jellyfinMediaProvider) TrackBeganPlayback(trackID string) error {
|
||||||
|
return j.client.UpdatePlayStatus(trackID, jellyfin.Start, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *jellyfinMediaProvider) TrackEndedPlayback(trackID string, position int, submission bool) error {
|
||||||
|
return j.client.UpdatePlayStatus(trackID, jellyfin.Stop, int64(position)*runTimeTicksPerSecond)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *jellyfinMediaProvider) RescanLibrary() error {
|
func (j *jellyfinMediaProvider) RescanLibrary() error {
|
||||||
|
|||||||
@@ -130,7 +130,14 @@ type MediaProvider interface {
|
|||||||
|
|
||||||
DeletePlaylist(id string) error
|
DeletePlaylist(id string) error
|
||||||
|
|
||||||
Scrobble(trackID string, submission bool) error
|
// True if the `submission` parameter to TrackEndedPlayback will be respected
|
||||||
|
// If false, the begin playback scrobble registers a play count immediately
|
||||||
|
// when TrackBeganPlayback is invoked.
|
||||||
|
ClientDecidesScrobble() bool
|
||||||
|
|
||||||
|
TrackBeganPlayback(trackID string) error
|
||||||
|
|
||||||
|
TrackEndedPlayback(trackID string, positionSecs int, submission bool) error
|
||||||
|
|
||||||
DownloadTrack(trackID string) (io.Reader, error)
|
DownloadTrack(trackID string) (io.Reader, error)
|
||||||
|
|
||||||
|
|||||||
@@ -256,10 +256,21 @@ func (s *subsonicMediaProvider) ReplacePlaylistTracks(playlistID string, trackID
|
|||||||
return s.client.CreatePlaylistWithTracks(trackIDs, map[string]string{"playlistId": playlistID})
|
return s.client.CreatePlaylistWithTracks(trackIDs, map[string]string{"playlistId": playlistID})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *subsonicMediaProvider) Scrobble(trackID string, submission bool) error {
|
func (s *subsonicMediaProvider) ClientDecidesScrobble() bool { return true }
|
||||||
|
|
||||||
|
func (s *subsonicMediaProvider) TrackBeganPlayback(trackID string) error {
|
||||||
return s.client.Scrobble(trackID, map[string]string{
|
return s.client.Scrobble(trackID, map[string]string{
|
||||||
"time": strconv.FormatInt(time.Now().UnixMilli(), 10),
|
"time": strconv.FormatInt(time.Now().UnixMilli(), 10),
|
||||||
"submission": strconv.FormatBool(submission)})
|
"submission": "false"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *subsonicMediaProvider) TrackEndedPlayback(trackID string, _ int, submission bool) error {
|
||||||
|
if !submission {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.client.Scrobble(trackID, map[string]string{
|
||||||
|
"time": strconv.FormatInt(time.Now().UnixMilli(), 10),
|
||||||
|
"submission": "true"})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *subsonicMediaProvider) SetFavorite(params mediaprovider.RatingFavoriteParameters, favorite bool) error {
|
func (s *subsonicMediaProvider) SetFavorite(params mediaprovider.RatingFavoriteParameters, favorite bool) error {
|
||||||
|
|||||||
+27
-13
@@ -35,9 +35,10 @@ type PlaybackManager struct {
|
|||||||
sm *ServerManager
|
sm *ServerManager
|
||||||
player *player.Player
|
player *player.Player
|
||||||
|
|
||||||
playTimeStopwatch util.Stopwatch
|
playTimeStopwatch util.Stopwatch
|
||||||
curTrackTime float64
|
curTrackTime float64
|
||||||
callbacksDisabled bool
|
latestTrackPosition float64 // cleared by checkScrobble
|
||||||
|
callbacksDisabled bool
|
||||||
|
|
||||||
playQueue []*mediaprovider.Track
|
playQueue []*mediaprovider.Track
|
||||||
nowPlayingIdx int64
|
nowPlayingIdx int64
|
||||||
@@ -73,15 +74,15 @@ func NewPlaybackManager(
|
|||||||
if tracknum >= int64(len(pm.playQueue)) {
|
if tracknum >= int64(len(pm.playQueue)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
pm.checkScrobble()
|
pm.checkScrobble() // scrobble the previous song if needed
|
||||||
if pm.player.GetStatus().State == player.Playing {
|
if pm.player.GetStatus().State == player.Playing {
|
||||||
pm.playTimeStopwatch.Start()
|
pm.playTimeStopwatch.Start()
|
||||||
}
|
}
|
||||||
pm.nowPlayingIdx = tracknum
|
pm.nowPlayingIdx = tracknum
|
||||||
pm.curTrackTime = float64(pm.playQueue[pm.nowPlayingIdx].Duration)
|
pm.curTrackTime = float64(pm.playQueue[pm.nowPlayingIdx].Duration)
|
||||||
|
pm.sendNowPlayingScrobble() // Must come before invokeOnChangeCallbacks b/c track may immediately be scrobbled
|
||||||
pm.invokeOnSongChangeCallbacks()
|
pm.invokeOnSongChangeCallbacks()
|
||||||
pm.doUpdateTimePos()
|
pm.doUpdateTimePos()
|
||||||
pm.sendNowPlayingScrobble()
|
|
||||||
})
|
})
|
||||||
p.OnSeek(func() {
|
p.OnSeek(func() {
|
||||||
pm.doUpdateTimePos()
|
pm.doUpdateTimePos()
|
||||||
@@ -380,13 +381,17 @@ func (p *PlaybackManager) checkScrobble() {
|
|||||||
pcnt := playDur.Seconds() / p.curTrackTime * 100
|
pcnt := playDur.Seconds() / p.curTrackTime * 100
|
||||||
timeThresholdMet := p.scrobbleCfg.ThresholdTimeSeconds >= 0 &&
|
timeThresholdMet := p.scrobbleCfg.ThresholdTimeSeconds >= 0 &&
|
||||||
playDur.Seconds() >= float64(p.scrobbleCfg.ThresholdTimeSeconds)
|
playDur.Seconds() >= float64(p.scrobbleCfg.ThresholdTimeSeconds)
|
||||||
if timeThresholdMet || pcnt >= float64(p.scrobbleCfg.ThresholdPercent) {
|
|
||||||
song := p.playQueue[p.nowPlayingIdx]
|
track := p.playQueue[p.nowPlayingIdx]
|
||||||
log.Printf("Scrobbling %q", song.Name)
|
var submission bool
|
||||||
song.PlayCount += 1
|
server := p.sm.Server
|
||||||
p.lastScrobbled = song
|
if server.ClientDecidesScrobble() && (timeThresholdMet || pcnt >= float64(p.scrobbleCfg.ThresholdPercent)) {
|
||||||
go p.sm.Server.Scrobble(song.ID, true)
|
track.PlayCount += 1
|
||||||
|
p.lastScrobbled = track
|
||||||
|
submission = true
|
||||||
}
|
}
|
||||||
|
go server.TrackEndedPlayback(track.ID, int(p.latestTrackPosition), submission)
|
||||||
|
p.latestTrackPosition = 0
|
||||||
p.playTimeStopwatch.Reset()
|
p.playTimeStopwatch.Reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,8 +399,14 @@ func (p *PlaybackManager) sendNowPlayingScrobble() {
|
|||||||
if !p.scrobbleCfg.Enabled || len(p.playQueue) == 0 || p.nowPlayingIdx < 0 {
|
if !p.scrobbleCfg.Enabled || len(p.playQueue) == 0 || p.nowPlayingIdx < 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
song := p.playQueue[p.nowPlayingIdx]
|
track := p.playQueue[p.nowPlayingIdx]
|
||||||
go p.sm.Server.Scrobble(song.ID, false)
|
server := p.sm.Server
|
||||||
|
if !server.ClientDecidesScrobble() {
|
||||||
|
// server will count track as scrobbled as soon as it starts playing
|
||||||
|
p.lastScrobbled = track
|
||||||
|
track.PlayCount += 1
|
||||||
|
}
|
||||||
|
go p.sm.Server.TrackBeganPlayback(track.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PlaybackManager) invokeOnSongChangeCallbacks() {
|
func (p *PlaybackManager) invokeOnSongChangeCallbacks() {
|
||||||
@@ -433,6 +444,9 @@ func (p *PlaybackManager) doUpdateTimePos() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
s := p.player.GetStatus()
|
s := p.player.GetStatus()
|
||||||
|
if s.TimePos > p.latestTrackPosition {
|
||||||
|
p.latestTrackPosition = s.TimePos
|
||||||
|
}
|
||||||
for _, cb := range p.onPlayTimeUpdate {
|
for _, cb := range p.onPlayTimeUpdate {
|
||||||
cb(s.TimePos, s.Duration)
|
cb(s.TimePos, s.Duration)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -461,7 +461,7 @@ func (c *Controller) ShowSettingsDialog(themeUpdateCallbk func(), themeFiles map
|
|||||||
}
|
}
|
||||||
|
|
||||||
bands := c.App.Player.Equalizer().BandFrequencies()
|
bands := c.App.Player.Equalizer().BandFrequencies()
|
||||||
dlg := dialogs.NewSettingsDialog(c.App.Config, devs, themeFiles, bands, c.MainWindow)
|
dlg := dialogs.NewSettingsDialog(c.App.Config, devs, themeFiles, bands, c.App.ServerManager.Server.ClientDecidesScrobble(), c.MainWindow)
|
||||||
dlg.OnReplayGainSettingsChanged = func() {
|
dlg.OnReplayGainSettingsChanged = func() {
|
||||||
c.App.PlaybackManager.SetReplayGainOptions(c.App.Config.ReplayGain)
|
c.App.PlaybackManager.SetReplayGainOptions(c.App.Config.ReplayGain)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ type SettingsDialog struct {
|
|||||||
themeFiles map[string]string // filename -> displayName
|
themeFiles map[string]string // filename -> displayName
|
||||||
promptText *widget.RichText
|
promptText *widget.RichText
|
||||||
|
|
||||||
|
clientDecidesScrobble bool
|
||||||
|
|
||||||
content fyne.CanvasObject
|
content fyne.CanvasObject
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,9 +54,10 @@ func NewSettingsDialog(
|
|||||||
audioDeviceList []player.AudioDevice,
|
audioDeviceList []player.AudioDevice,
|
||||||
themeFileList map[string]string,
|
themeFileList map[string]string,
|
||||||
equalizerBands []string,
|
equalizerBands []string,
|
||||||
|
clientDecidesScrobble bool,
|
||||||
window fyne.Window,
|
window fyne.Window,
|
||||||
) *SettingsDialog {
|
) *SettingsDialog {
|
||||||
s := &SettingsDialog{config: config, audioDevices: audioDeviceList, themeFiles: themeFileList}
|
s := &SettingsDialog{config: config, audioDevices: audioDeviceList, themeFiles: themeFileList, clientDecidesScrobble: clientDecidesScrobble}
|
||||||
s.ExtendBaseWidget(s)
|
s.ExtendBaseWidget(s)
|
||||||
|
|
||||||
tabs := container.NewAppTabs(
|
tabs := container.NewAppTabs(
|
||||||
@@ -188,7 +191,9 @@ func (s *SettingsDialog) createGeneralTab() *container.TabItem {
|
|||||||
durationEntry.Disable()
|
durationEntry.Disable()
|
||||||
} else {
|
} else {
|
||||||
durationEntry.Text = lastScrobbleText
|
durationEntry.Text = lastScrobbleText
|
||||||
durationEntry.Enable()
|
if s.clientDecidesScrobble {
|
||||||
|
durationEntry.Enable()
|
||||||
|
}
|
||||||
durationEntry.Refresh()
|
durationEntry.Refresh()
|
||||||
durationEntry.OnChanged(durationEntry.Text)
|
durationEntry.OnChanged(durationEntry.Text)
|
||||||
}
|
}
|
||||||
@@ -197,6 +202,10 @@ func (s *SettingsDialog) createGeneralTab() *container.TabItem {
|
|||||||
if !s.config.Scrobbling.Enabled {
|
if !s.config.Scrobbling.Enabled {
|
||||||
durationEnabled.Disable()
|
durationEnabled.Disable()
|
||||||
}
|
}
|
||||||
|
if !s.clientDecidesScrobble {
|
||||||
|
percentEntry.Disable()
|
||||||
|
durationEnabled.Disable()
|
||||||
|
}
|
||||||
|
|
||||||
scrobbleEnabled := widget.NewCheck("Send playback statistics to server", func(checked bool) {
|
scrobbleEnabled := widget.NewCheck("Send playback statistics to server", func(checked bool) {
|
||||||
s.config.Scrobbling.Enabled = checked
|
s.config.Scrobbling.Enabled = checked
|
||||||
@@ -205,9 +214,11 @@ func (s *SettingsDialog) createGeneralTab() *container.TabItem {
|
|||||||
durationEnabled.Disable()
|
durationEnabled.Disable()
|
||||||
durationEntry.Disable()
|
durationEntry.Disable()
|
||||||
} else {
|
} else {
|
||||||
percentEntry.Enable()
|
if s.clientDecidesScrobble {
|
||||||
durationEnabled.Enable()
|
percentEntry.Enable()
|
||||||
if durationEnabled.Checked {
|
durationEnabled.Enable()
|
||||||
|
}
|
||||||
|
if durationEnabled.Checked && s.clientDecidesScrobble {
|
||||||
durationEntry.Enable()
|
durationEntry.Enable()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user