move scrobblers into their own package

This commit is contained in:
Alex McGrath
2021-01-10 17:29:04 +00:00
committed by Senan Kelly
parent b9998f7ee6
commit 4443d7d0f5
10 changed files with 198 additions and 195 deletions

View File

@@ -9,8 +9,8 @@ import (
"go.senan.xyz/gonic/server/db"
"go.senan.xyz/gonic/server/encode"
"go.senan.xyz/gonic/server/lastfm"
"go.senan.xyz/gonic/server/scanner"
"go.senan.xyz/gonic/server/scrobble/lastfm"
)
func firstExisting(or string, strings ...string) string {

View File

@@ -13,7 +13,7 @@ import (
"go.senan.xyz/gonic/server/ctrlsubsonic/params"
"go.senan.xyz/gonic/server/ctrlsubsonic/spec"
"go.senan.xyz/gonic/server/jukebox"
"go.senan.xyz/gonic/server/lastfm"
"go.senan.xyz/gonic/server/scrobble"
)
type CtxKey int
@@ -29,7 +29,7 @@ type Controller struct {
CachePath string
CoverCachePath string
Jukebox *jukebox.Jukebox
Scrobblers []lastfm.Scrobbler
Scrobblers []scrobble.Scrobbler
}
type metaResponse struct {

View File

@@ -11,7 +11,7 @@ import (
"go.senan.xyz/gonic/server/ctrlsubsonic/spec"
"go.senan.xyz/gonic/server/ctrlsubsonic/specid"
"go.senan.xyz/gonic/server/db"
"go.senan.xyz/gonic/server/lastfm"
"go.senan.xyz/gonic/server/scrobble/lastfm"
)
func (c *Controller) ServeGetArtists(r *http.Request) *spec.Response {

View File

@@ -12,7 +12,6 @@ import (
"go.senan.xyz/gonic/server/ctrlsubsonic/spec"
"go.senan.xyz/gonic/server/ctrlsubsonic/specid"
"go.senan.xyz/gonic/server/db"
"go.senan.xyz/gonic/server/lastfm"
"go.senan.xyz/gonic/server/scanner"
)
@@ -50,20 +49,13 @@ func (c *Controller) ServeScrobble(r *http.Request) *spec.Response {
Preload("Album").
Preload("Artist").
First(track, id.Value)
// scrobble with above info
opts := lastfm.ScrobbleOptions{
Track: track,
// clients will provide time in miliseconds, so use that or
// instead convert UnixNano to miliseconds
StampMili: params.GetOrInt("time", int(time.Now().UnixNano()/1e6)),
Submission: params.GetOrBool("submission", true),
}
optStampMili := params.GetOrInt("time", int(time.Now().UnixNano()/1e6))
optSubmission := params.GetOrBool("submission", true)
scrobbleErrs := []error{}
for _, scrobbler := range c.Scrobblers {
if !scrobbler.Enabled(user) {
continue
}
err = scrobbler.Scrobble(user, opts)
err = scrobbler.Scrobble(user, track, optStampMili, optSubmission)
scrobbleErrs = append(scrobbleErrs, err)
}
if len(scrobbleErrs) != 0 {

View File

@@ -1,86 +0,0 @@
package lastfm
import (
"encoding/xml"
"go.senan.xyz/gonic/server/db"
)
type Scrobbler interface {
Scrobble(*db.User, ScrobbleOptions) error
Enabled(*db.User) bool
}
type LastFM struct {
XMLName xml.Name `xml:"lfm"`
Status string `xml:"status,attr"`
Session Session `xml:"session"`
Error Error `xml:"error"`
Artist Artist `xml:"artist"`
}
type Session struct {
Name string `xml:"name"`
Key string `xml:"key"`
Subscriber uint `xml:"subscriber"`
}
type Error struct {
Code uint `xml:"code,attr"`
Value string `xml:",chardata"`
}
type Artist struct {
XMLName xml.Name `xml:"artist"`
Name string `xml:"name"`
MBID string `xml:"mbid"`
URL string `xml:"url"`
Image []struct {
Text string `xml:",chardata"`
Size string `xml:"size,attr"`
} `xml:"image"`
Streamable string `xml:"streamable"`
Stats struct {
Listeners string `xml:"listeners"`
Plays string `xml:"plays"`
} `xml:"stats"`
Similar struct {
Artists []Artist `xml:"artist"`
} `xml:"similar"`
Tags struct {
Tag []ArtistTag `xml:"tag"`
} `xml:"tags"`
Bio ArtistBio `xml:"bio"`
}
type ArtistTag struct {
Name string `xml:"name"`
URL string `xml:"url"`
}
type ArtistBio struct {
Published string `xml:"published"`
Summary string `xml:"summary"`
Content string `xml:"content"`
}
type ListenBrainzAdditionalInfo struct {
TrackNumber int `json:"tracknumber"`
}
type ListenBrainzTrackMetadata struct {
AdditionalInfo ListenBrainzAdditionalInfo `json:"additional_info"`
ArtistName string `json:"artist_name"`
TrackName string `json:"track_name"`
ReleaseName string `json:"release_name"`
}
type ListenBrainzPayload struct {
ListenedAt int `json:"listened_at"`
TrackMetadata ListenBrainzTrackMetadata `json:"track_metadata"`
}
type ListenBrainzScrobble struct {
ListenType string `json:"listen_type"`
Payload []ListenBrainzPayload `json:"payload"`
}

View File

@@ -1,10 +1,8 @@
package lastfm
import (
"bytes"
"crypto/md5"
"encoding/hex"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
@@ -14,19 +12,69 @@ import (
"strconv"
"go.senan.xyz/gonic/server/db"
"go.senan.xyz/gonic/server/scrobble"
)
const (
lastfmBaseURL = "https://ws.audioscrobbler.com/2.0/"
lbBaseURL = "https://api.listenbrainz.org"
baseURL = "https://ws.audioscrobbler.com/2.0/"
)
var (
ErrLastFM = errors.New("last.fm error")
ErrListenBrainz = errors.New("listenbrainz error")
)
// TODO: remove this package's dependency on models/db
type LastFM struct {
XMLName xml.Name `xml:"lfm"`
Status string `xml:"status,attr"`
Session Session `xml:"session"`
Error Error `xml:"error"`
Artist Artist `xml:"artist"`
}
type Session struct {
Name string `xml:"name"`
Key string `xml:"key"`
Subscriber uint `xml:"subscriber"`
}
type Error struct {
Code uint `xml:"code,attr"`
Value string `xml:",chardata"`
}
type Artist struct {
XMLName xml.Name `xml:"artist"`
Name string `xml:"name"`
MBID string `xml:"mbid"`
URL string `xml:"url"`
Image []struct {
Text string `xml:",chardata"`
Size string `xml:"size,attr"`
} `xml:"image"`
Streamable string `xml:"streamable"`
Stats struct {
Listeners string `xml:"listeners"`
Plays string `xml:"plays"`
} `xml:"stats"`
Similar struct {
Artists []Artist `xml:"artist"`
} `xml:"similar"`
Tags struct {
Tag []ArtistTag `xml:"tag"`
} `xml:"tags"`
Bio ArtistBio `xml:"bio"`
}
type ArtistTag struct {
Name string `xml:"name"`
URL string `xml:"url"`
}
type ArtistBio struct {
Published string `xml:"published"`
Summary string `xml:"summary"`
Content string `xml:"content"`
}
func getParamSignature(params url.Values, secret string) string {
// the parameters must be in order before hashing
@@ -46,7 +94,7 @@ func getParamSignature(params url.Values, secret string) string {
}
func makeRequest(method string, params url.Values) (LastFM, error) {
req, _ := http.NewRequest(method, lastfmBaseURL, nil)
req, _ := http.NewRequest(method, baseURL, nil)
req.URL.RawQuery = params.Encode()
resp, err := http.DefaultClient.Do(req)
if err != nil {
@@ -64,6 +112,18 @@ func makeRequest(method string, params url.Values) (LastFM, error) {
return lastfm, nil
}
func ArtistGetInfo(apiKey string, artist *db.Artist) (Artist, error) {
params := url.Values{}
params.Add("method", "artist.getInfo")
params.Add("api_key", apiKey)
params.Add("artist", artist.Name)
resp, err := makeRequest("GET", params)
if err != nil {
return Artist{}, fmt.Errorf("making artist GET: %w", err)
}
return resp.Artist, nil
}
func GetSession(apiKey, secret, token string) (string, error) {
params := url.Values{}
params.Add("method", "auth.getSession")
@@ -77,101 +137,39 @@ func GetSession(apiKey, secret, token string) (string, error) {
return resp.Session.Key, nil
}
type ScrobbleOptions struct {
Track *db.Track
StampMili int
Submission bool
}
type LastfmScrobbler struct { //nolint
type Scrobbler struct {
DB *db.DB
}
func (lfm *LastfmScrobbler) Scrobble(user *db.User, opts ScrobbleOptions) error {
apiKey := lfm.DB.GetSetting("lastfm_api_key")
secret := lfm.DB.GetSetting("lastfm_secret")
func (s *Scrobbler) Scrobble(user *db.User, track *db.Track, stampMili int, submission bool) error {
if user.LastFMSession == "" {
return nil
}
apiKey := s.DB.GetSetting("lastfm_api_key")
secret := s.DB.GetSetting("lastfm_secret")
// fetch user to get lastfm session
if user.LastFMSession == "" {
return fmt.Errorf("you don't have a last.fm session: %w", ErrLastFM)
}
params := url.Values{}
if opts.Submission {
if submission {
params.Add("method", "track.Scrobble")
// last.fm wants the timestamp in seconds
params.Add("timestamp", strconv.Itoa(opts.StampMili/1e3))
params.Add("timestamp", strconv.Itoa(stampMili/1e3))
} else {
params.Add("method", "track.updateNowPlaying")
}
params.Add("api_key", apiKey)
params.Add("sk", user.LastFMSession)
params.Add("artist", opts.Track.TagTrackArtist)
params.Add("track", opts.Track.TagTitle)
params.Add("trackNumber", strconv.Itoa(opts.Track.TagTrackNumber))
params.Add("album", opts.Track.Album.TagTitle)
params.Add("mbid", opts.Track.TagBrainzID)
params.Add("albumArtist", opts.Track.Artist.Name)
params.Add("artist", track.TagTrackArtist)
params.Add("track", track.TagTitle)
params.Add("trackNumber", strconv.Itoa(track.TagTrackNumber))
params.Add("album", track.Album.TagTitle)
params.Add("mbid", track.TagBrainzID)
params.Add("albumArtist", track.Artist.Name)
params.Add("api_sig", getParamSignature(params, secret))
_, err := makeRequest("POST", params)
return err
}
func (lfm *LastfmScrobbler) Enabled(user *db.User) bool {
return user.LastFMSession != ""
}
func ArtistGetInfo(apiKey string, artist *db.Artist) (Artist, error) {
params := url.Values{}
params.Add("method", "artist.getInfo")
params.Add("api_key", apiKey)
params.Add("artist", artist.Name)
resp, err := makeRequest("GET", params)
if err != nil {
return Artist{}, fmt.Errorf("making artist GET: %w", err)
}
return resp.Artist, nil
}
type ListenBrainzScrobbler struct {
DB *db.DB
}
func (lb *ListenBrainzScrobbler) Scrobble(user *db.User, opts ScrobbleOptions) error {
listenType := "single"
if !opts.Submission {
listenType = "playing_now"
}
scrobble := ListenBrainzScrobble{
ListenType: listenType,
Payload: []ListenBrainzPayload{{
ListenedAt: opts.StampMili / 1e3,
TrackMetadata: ListenBrainzTrackMetadata{
AdditionalInfo: ListenBrainzAdditionalInfo{
TrackNumber: opts.Track.TagTrackNumber,
},
ArtistName: opts.Track.TagTrackArtist,
TrackName: opts.Track.TagTitle,
ReleaseName: opts.Track.Album.TagTitle,
},
}},
}
payloadBuf := bytes.Buffer{}
if err := json.NewEncoder(&payloadBuf).Encode(scrobble); err != nil {
return err
}
req, _ := http.NewRequest("POST", lbBaseURL+"/1/submit-listens", &payloadBuf)
req.Header.Add("Authorization", "Token "+user.ListenBrainzSession)
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
if res.StatusCode == http.StatusUnauthorized {
return fmt.Errorf("unathorized error scrobbling to listenbrainz %w",
ErrListenBrainz)
}
res.Body.Close()
return nil
}
func (lb *ListenBrainzScrobbler) Enabled(user *db.User) bool {
return user.ListenBrainzSession != ""
}
var _ scrobble.Scrobbler = (*Scrobbler)(nil)

View File

@@ -0,0 +1,90 @@
package listenbrainz
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"go.senan.xyz/gonic/server/db"
"go.senan.xyz/gonic/server/scrobble"
)
const (
baseURL = "https://api.listenbrainz.org"
submitPath = "/1/submit-listens"
listenTypeSingle = "single"
listenTypePlayingNow = "playing_now"
)
var (
ErrListenBrainz = errors.New("listenbrainz error")
)
type AdditionalInfo struct {
TrackNumber int `json:"tracknumber"`
}
type TrackMetadata struct {
AdditionalInfo AdditionalInfo `json:"additional_info"`
ArtistName string `json:"artist_name"`
TrackName string `json:"track_name"`
ReleaseName string `json:"release_name"`
}
type Payload struct {
ListenedAt int `json:"listened_at"`
TrackMetadata TrackMetadata `json:"track_metadata"`
}
type Scrobble struct {
ListenType string `json:"listen_type"`
Payload []Payload `json:"payload"`
}
type Scrobbler struct{}
func (s *Scrobbler) Scrobble(user *db.User, track *db.Track, stampMili int, submission bool) error {
if user.ListenBrainzSession == "" {
return nil
}
payload := Payload{
ListenedAt: stampMili / 1e3,
TrackMetadata: TrackMetadata{
AdditionalInfo: AdditionalInfo{
TrackNumber: track.TagTrackNumber,
},
ArtistName: track.TagTrackArtist,
TrackName: track.TagTitle,
ReleaseName: track.Album.TagTitle,
},
}
scrobble := Scrobble{
ListenType: listenTypeSingle,
Payload: []Payload{payload},
}
if !submission {
scrobble.ListenType = listenTypePlayingNow
}
payloadBuf := bytes.Buffer{}
if err := json.NewEncoder(&payloadBuf).Encode(scrobble); err != nil {
return err
}
submitURL := fmt.Sprintf("%s%s", baseURL, submitPath)
authHeader := fmt.Sprintf("Token %s", user.ListenBrainzSession)
req, _ := http.NewRequest("POST", submitURL, &payloadBuf)
req.Header.Add("Authorization", authHeader)
res, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("http post: %w", err)
}
if res.StatusCode == http.StatusUnauthorized {
return fmt.Errorf("unathorized error scrobbling to listenbrainz %w", ErrListenBrainz)
}
res.Body.Close()
return nil
}
var _ scrobble.Scrobbler = (*Scrobbler)(nil)

View File

@@ -0,0 +1,9 @@
package scrobble
import (
"go.senan.xyz/gonic/server/db"
)
type Scrobbler interface {
Scrobble(user *db.User, track *db.Track, stampMili int, submission bool) error
}

View File

@@ -17,8 +17,10 @@ import (
"go.senan.xyz/gonic/server/ctrlsubsonic"
"go.senan.xyz/gonic/server/db"
"go.senan.xyz/gonic/server/jukebox"
"go.senan.xyz/gonic/server/lastfm"
"go.senan.xyz/gonic/server/scanner"
"go.senan.xyz/gonic/server/scrobble"
"go.senan.xyz/gonic/server/scrobble/lastfm"
"go.senan.xyz/gonic/server/scrobble/listenbrainz"
)
type Options struct {
@@ -63,11 +65,9 @@ func New(opts Options) *Server {
sessDB.SessionOpts.SameSite = http.SameSiteLaxMode
//
ctrlAdmin := ctrladmin.New(base, sessDB)
lastfmScrobbler := &lastfm.LastfmScrobbler{DB: opts.DB}
listenbrainzScrobbler := &lastfm.ListenBrainzScrobbler{DB: opts.DB}
scrobblers := []lastfm.Scrobbler{
lastfmScrobbler,
listenbrainzScrobbler,
scrobblers := []scrobble.Scrobbler{
&lastfm.Scrobbler{DB: opts.DB},
&listenbrainz.Scrobbler{},
}
ctrlSubsonic := &ctrlsubsonic.Controller{
Controller: base,