refactor scrobblers (#383)

- no need to explicitly pass api key
- move packages up a level
- catch more errors by extended scrobbler interface with IsUserAuthenticated
- move interface to server
- delete scrobbber package, clients implicitly satisfy Scrobble

this also helps with gonic-lastfm-sync
This commit is contained in:
Senan Kelly
2023-09-27 01:13:00 +01:00
committed by GitHub
parent 32064d0279
commit f119659acf
27 changed files with 1100 additions and 1144 deletions

View File

@@ -0,0 +1,100 @@
package listenbrainz
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"net/http/httputil"
"time"
"github.com/google/uuid"
"go.senan.xyz/gonic/db"
)
const (
BaseURL = "https://api.listenbrainz.org"
submitPath = "/1/submit-listens"
listenTypeSingle = "single"
listenTypePlayingNow = "playing_now"
)
var ErrListenBrainz = errors.New("listenbrainz error")
type Client struct {
httpClient *http.Client
}
func NewClient() *Client {
return NewClientCustom(http.DefaultClient)
}
func NewClientCustom(httpClient *http.Client) *Client {
return &Client{httpClient: httpClient}
}
func (c *Client) IsUserAuthenticated(user *db.User) bool {
return user.ListenBrainzURL != "" && user.ListenBrainzToken != ""
}
func (c *Client) Scrobble(user *db.User, track *db.Track, stamp time.Time, submission bool) error {
// make sure we provide a valid uuid, since some users may have an incorrect mbid in their tags
var trackMBID string
if _, err := uuid.Parse(track.TagBrainzID); err == nil {
trackMBID = track.TagBrainzID
}
payload := &Payload{
TrackMetadata: &TrackMetadata{
AdditionalInfo: &AdditionalInfo{
TrackNumber: track.TagTrackNumber,
RecordingMBID: trackMBID,
TrackLength: track.Length,
},
ArtistName: track.TagTrackArtist,
TrackName: track.TagTitle,
ReleaseName: track.Album.TagTitle,
},
}
scrobble := Scrobble{
Payload: []*Payload{payload},
}
if submission && len(scrobble.Payload) > 0 {
scrobble.ListenType = listenTypeSingle
scrobble.Payload[0].ListenedAt = int(stamp.Unix())
} else {
scrobble.ListenType = listenTypePlayingNow
}
var payloadBuf bytes.Buffer
if err := json.NewEncoder(&payloadBuf).Encode(scrobble); err != nil {
return err
}
submitURL := fmt.Sprintf("%s%s", user.ListenBrainzURL, submitPath)
authHeader := fmt.Sprintf("Token %s", user.ListenBrainzToken)
req, _ := http.NewRequest(http.MethodPost, submitURL, &payloadBuf)
req.Header.Add("Content-Type", "application/json")
req.Header.Add("Authorization", authHeader)
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("http post: %w", err)
}
defer resp.Body.Close()
switch {
case resp.StatusCode == http.StatusUnauthorized:
return fmt.Errorf("unauthorized: %w", ErrListenBrainz)
case resp.StatusCode >= 400:
respBytes, _ := httputil.DumpResponse(resp, true)
log.Printf("received bad listenbrainz response:\n%s", string(respBytes))
return fmt.Errorf(">= 400: %d: %w", resp.StatusCode, ErrListenBrainz)
}
return nil
}

View File

@@ -0,0 +1,114 @@
package listenbrainz_test
import (
"context"
"crypto/tls"
_ "embed"
"io"
"net"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/require"
"go.senan.xyz/gonic/db"
"go.senan.xyz/gonic/listenbrainz"
)
func TestScrobble(t *testing.T) {
t.Parallel()
client := listenbrainz.NewClientCustom(
newMockClient(t, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
require.Equal(t, "/1/submit-listens", r.URL.Path)
require.Equal(t, "application/json", r.Header.Get("Content-Type"))
require.Equal(t, "Token token1", r.Header.Get("Authorization"))
bodyBytes, err := io.ReadAll(r.Body)
require.NoError(t, err)
require.JSONEq(t, submitListensRequest, string(bodyBytes))
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"accepted": 1}`))
}),
)
err := client.Scrobble(
&db.User{ListenBrainzURL: "https://listenbrainz.org", ListenBrainzToken: "token1"},
&db.Track{Album: &db.Album{TagTitle: "album"}, TagTitle: "title", TagTrackArtist: "artist", TagTrackNumber: 1},
time.Unix(1683804525, 0),
true,
)
require.NoError(t, err)
}
func TestScrobbleUnauthorized(t *testing.T) {
t.Parallel()
client := listenbrainz.NewClientCustom(
newMockClient(t, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
require.Equal(t, "/1/submit-listens", r.URL.Path)
require.Equal(t, "application/json", r.Header.Get("Content-Type"))
require.Equal(t, "Token token1", r.Header.Get("Authorization"))
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"code": 401, "error": "Invalid authorization token."}`))
}),
)
err := client.Scrobble(
&db.User{ListenBrainzURL: "https://listenbrainz.org", ListenBrainzToken: "token1"},
&db.Track{Album: &db.Album{TagTitle: "album"}, TagTitle: "title", TagTrackArtist: "artist", TagTrackNumber: 1},
time.Now(),
true,
)
require.ErrorIs(t, err, listenbrainz.ErrListenBrainz)
}
func TestScrobbleServerError(t *testing.T) {
t.Parallel()
client := listenbrainz.NewClientCustom(
newMockClient(t, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
require.Equal(t, "/1/submit-listens", r.URL.Path)
require.Equal(t, "application/json", r.Header.Get("Content-Type"))
require.Equal(t, "Token token1", r.Header.Get("Authorization"))
w.WriteHeader(http.StatusInternalServerError)
}),
)
err := client.Scrobble(
&db.User{ListenBrainzURL: "https://listenbrainz.org", ListenBrainzToken: "token1"},
&db.Track{Album: &db.Album{TagTitle: "album"}, TagTitle: "title", TagTrackArtist: "artist", TagTrackNumber: 1},
time.Now(),
true,
)
require.ErrorIs(t, err, listenbrainz.ErrListenBrainz)
}
func newMockClient(tb testing.TB, handler http.HandlerFunc) *http.Client {
tb.Helper()
server := httptest.NewTLSServer(handler)
tb.Cleanup(server.Close)
return &http.Client{
Transport: &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return net.Dial(network, server.Listener.Addr().String())
},
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true, //nolint:gosec
},
},
}
}
//go:embed testdata/submit_listens_request.json
var submitListensRequest string

29
listenbrainz/model.go Normal file
View File

@@ -0,0 +1,29 @@
package listenbrainz
// https://listenbrainz.readthedocs.io/en/latest/users/json.html#submission-json
type (
Payload struct {
ListenedAt int `json:"listened_at,omitempty"`
TrackMetadata *TrackMetadata `json:"track_metadata"`
}
AdditionalInfo struct {
TrackNumber int `json:"tracknumber,omitempty"`
TrackMBID string `json:"track_mbid,omitempty"`
RecordingMBID string `json:"recording_mbid,omitempty"`
TrackLength int `json:"track_length,omitempty"`
}
TrackMetadata struct {
AdditionalInfo *AdditionalInfo `json:"additional_info"`
ArtistName string `json:"artist_name,omitempty"`
TrackName string `json:"track_name,omitempty"`
ReleaseName string `json:"release_name,omitempty"`
}
Scrobble struct {
ListenType string `json:"listen_type,omitempty"`
Payload []*Payload `json:"payload"`
}
)

View File

@@ -0,0 +1,16 @@
{
"listen_type": "single",
"payload": [
{
"listened_at": 1683804525,
"track_metadata": {
"additional_info": {
"tracknumber": 1
},
"artist_name": "artist",
"track_name": "title",
"release_name": "album"
}
}
]
}