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:
100
listenbrainz/listenbrainz.go
Normal file
100
listenbrainz/listenbrainz.go
Normal 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
|
||||
}
|
||||
114
listenbrainz/listenbrainz_test.go
Normal file
114
listenbrainz/listenbrainz_test.go
Normal 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
29
listenbrainz/model.go
Normal 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"`
|
||||
}
|
||||
)
|
||||
16
listenbrainz/testdata/submit_listens_request.json
vendored
Normal file
16
listenbrainz/testdata/submit_listens_request.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user