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:
@@ -1,177 +0,0 @@
|
||||
package lastfm
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
|
||||
"github.com/andybalholm/cascadia"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
const (
|
||||
baseURL = "https://ws.audioscrobbler.com/2.0/"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrLastFM = errors.New("last.fm error")
|
||||
|
||||
//nolint:gochecknoglobals
|
||||
artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`)
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewClientCustom(httpClient *http.Client) *Client {
|
||||
return &Client{httpClient: httpClient}
|
||||
}
|
||||
|
||||
func NewClient() *Client {
|
||||
return NewClientCustom(http.DefaultClient)
|
||||
}
|
||||
|
||||
func getParamSignature(params url.Values, secret string) string {
|
||||
// the parameters must be in order before hashing
|
||||
paramKeys := make([]string, 0, len(params))
|
||||
for k := range params {
|
||||
paramKeys = append(paramKeys, k)
|
||||
}
|
||||
sort.Strings(paramKeys)
|
||||
toHash := ""
|
||||
for _, k := range paramKeys {
|
||||
toHash += k
|
||||
toHash += params[k][0]
|
||||
}
|
||||
toHash += secret
|
||||
hash := md5.Sum([]byte(toHash))
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
func (c *Client) makeRequest(method string, params url.Values) (LastFM, error) {
|
||||
req, _ := http.NewRequest(method, baseURL, nil)
|
||||
req.URL.RawQuery = params.Encode()
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return LastFM{}, fmt.Errorf("get: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
decoder := xml.NewDecoder(resp.Body)
|
||||
lastfm := LastFM{}
|
||||
if err = decoder.Decode(&lastfm); err != nil {
|
||||
return LastFM{}, fmt.Errorf("decoding: %w", err)
|
||||
}
|
||||
if lastfm.Error.Code != 0 {
|
||||
return LastFM{}, fmt.Errorf("%v: %w", lastfm.Error.Value, ErrLastFM)
|
||||
}
|
||||
return lastfm, nil
|
||||
}
|
||||
|
||||
func (c *Client) ArtistGetInfo(apiKey string, artistName string) (Artist, error) {
|
||||
params := url.Values{}
|
||||
params.Add("method", "artist.getInfo")
|
||||
params.Add("api_key", apiKey)
|
||||
params.Add("artist", artistName)
|
||||
resp, err := c.makeRequest(http.MethodGet, params)
|
||||
if err != nil {
|
||||
return Artist{}, fmt.Errorf("making artist GET: %w", err)
|
||||
}
|
||||
return resp.Artist, nil
|
||||
}
|
||||
|
||||
func (c *Client) ArtistGetTopTracks(apiKey, artistName string) (TopTracks, error) {
|
||||
params := url.Values{}
|
||||
params.Add("method", "artist.getTopTracks")
|
||||
params.Add("api_key", apiKey)
|
||||
params.Add("artist", artistName)
|
||||
resp, err := c.makeRequest(http.MethodGet, params)
|
||||
if err != nil {
|
||||
return TopTracks{}, fmt.Errorf("making track GET: %w", err)
|
||||
}
|
||||
return resp.TopTracks, nil
|
||||
}
|
||||
|
||||
func (c *Client) TrackGetSimilarTracks(apiKey string, artistName, trackName string) (SimilarTracks, error) {
|
||||
params := url.Values{}
|
||||
params.Add("method", "track.getSimilar")
|
||||
params.Add("api_key", apiKey)
|
||||
params.Add("track", trackName)
|
||||
params.Add("artist", artistName)
|
||||
resp, err := c.makeRequest(http.MethodGet, params)
|
||||
if err != nil {
|
||||
return SimilarTracks{}, fmt.Errorf("making track GET: %w", err)
|
||||
}
|
||||
return resp.SimilarTracks, nil
|
||||
}
|
||||
|
||||
func (c *Client) ArtistGetSimilar(apiKey string, artistName string) (SimilarArtists, error) {
|
||||
params := url.Values{}
|
||||
params.Add("method", "artist.getSimilar")
|
||||
params.Add("api_key", apiKey)
|
||||
params.Add("artist", artistName)
|
||||
resp, err := c.makeRequest(http.MethodGet, params)
|
||||
if err != nil {
|
||||
return SimilarArtists{}, fmt.Errorf("making similar artists GET: %w", err)
|
||||
}
|
||||
return resp.SimilarArtists, nil
|
||||
}
|
||||
|
||||
func (c *Client) UserGetLovedTracks(apiKey string, userName string) (LovedTracks, error) {
|
||||
params := url.Values{}
|
||||
params.Add("method", "user.getLovedTracks")
|
||||
params.Add("api_key", apiKey)
|
||||
params.Add("user", userName)
|
||||
params.Add("limit", "1000") // TODO: paginate
|
||||
resp, err := c.makeRequest(http.MethodGet, params)
|
||||
if err != nil {
|
||||
return LovedTracks{}, fmt.Errorf("making user get loved tracks GET: %w", err)
|
||||
}
|
||||
return resp.LovedTracks, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetSession(apiKey, secret, token string) (string, error) {
|
||||
params := url.Values{}
|
||||
params.Add("method", "auth.getSession")
|
||||
params.Add("api_key", apiKey)
|
||||
params.Add("token", token)
|
||||
params.Add("api_sig", getParamSignature(params, secret))
|
||||
resp, err := c.makeRequest(http.MethodGet, params)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("making session GET: %w", err)
|
||||
}
|
||||
return resp.Session.Key, nil
|
||||
}
|
||||
|
||||
func (c *Client) StealArtistImage(artistURL string) (string, error) {
|
||||
resp, err := http.Get(artistURL) //nolint:gosec
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get artist url: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
node, err := html.Parse(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse html: %w", err)
|
||||
}
|
||||
|
||||
n := cascadia.Query(node, artistOpenGraphQuery)
|
||||
if n == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var imageURL string
|
||||
for _, attr := range n.Attr {
|
||||
if attr.Key == "content" {
|
||||
imageURL = attr.Val
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return imageURL, nil
|
||||
}
|
||||
@@ -1,488 +0,0 @@
|
||||
package lastfm
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.senan.xyz/gonic/scrobble/lastfm/mockclient"
|
||||
)
|
||||
|
||||
func TestArtistGetInfo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// arrange
|
||||
client := Client{mockclient.New(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, http.MethodGet, r.Method)
|
||||
require.Equal(t, url.Values{
|
||||
"method": []string{"artist.getInfo"},
|
||||
"api_key": []string{"apiKey1"},
|
||||
"artist": []string{"Artist 1"},
|
||||
}, r.URL.Query())
|
||||
|
||||
require.Equal(t, "/2.0/", r.URL.Path)
|
||||
require.Equal(t, baseURL, "https://"+r.Host+r.URL.Path)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(mockclient.ArtistGetInfoResponse)
|
||||
})}
|
||||
|
||||
// act
|
||||
actual, err := client.ArtistGetInfo("apiKey1", "Artist 1")
|
||||
|
||||
// assert
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, Artist{
|
||||
XMLName: xml.Name{
|
||||
Local: "artist",
|
||||
},
|
||||
Name: "Artist 1",
|
||||
MBID: "366c1119-ec4f-4312-b729-a5637d148e3e",
|
||||
Streamable: "0",
|
||||
Stats: struct {
|
||||
Listeners string `xml:"listeners"`
|
||||
Playcount string `xml:"playcount"`
|
||||
}{
|
||||
Listeners: "1",
|
||||
Playcount: "2",
|
||||
},
|
||||
URL: "https://www.last.fm/music/Artist+1",
|
||||
Image: []Image{
|
||||
{
|
||||
Size: "small",
|
||||
Text: "https://last.fm/artist-1-small.png",
|
||||
},
|
||||
},
|
||||
Bio: ArtistBio{
|
||||
Published: "13 May 2023, 00:24",
|
||||
Summary: "Summary",
|
||||
Content: "Content",
|
||||
},
|
||||
Similar: struct {
|
||||
Artists []Artist `xml:"artist"`
|
||||
}{
|
||||
Artists: []Artist{
|
||||
{
|
||||
XMLName: xml.Name{
|
||||
Local: "artist",
|
||||
},
|
||||
Name: "Similar Artist 1",
|
||||
URL: "https://www.last.fm/music/Similar+Artist+1",
|
||||
Image: []Image{
|
||||
{
|
||||
Size: "small",
|
||||
Text: "https://last.fm/similar-artist-1-small.png",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Tags: struct {
|
||||
Tag []ArtistTag `xml:"tag"`
|
||||
}{
|
||||
Tag: []ArtistTag{
|
||||
{
|
||||
Name: "tag1",
|
||||
URL: "https://www.last.fm/tag/tag1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}, actual)
|
||||
}
|
||||
|
||||
func TestArtistGetInfoClientRequestFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// arrange
|
||||
client := Client{mockclient.New(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, http.MethodGet, r.Method)
|
||||
require.Equal(t, url.Values{
|
||||
"method": []string{"artist.getInfo"},
|
||||
"api_key": []string{"apiKey1"},
|
||||
"artist": []string{"Artist 1"},
|
||||
}, r.URL.Query())
|
||||
|
||||
require.Equal(t, "/2.0/", r.URL.Path)
|
||||
require.Equal(t, baseURL, "https://"+r.Host+r.URL.Path)
|
||||
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
})}
|
||||
|
||||
// act
|
||||
actual, err := client.ArtistGetInfo("apiKey1", "Artist 1")
|
||||
|
||||
// assert
|
||||
require.Error(t, err)
|
||||
require.Zero(t, actual)
|
||||
}
|
||||
|
||||
func TestArtistGetTopTracks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// arrange
|
||||
client := Client{mockclient.New(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, http.MethodGet, r.Method)
|
||||
require.Equal(t, url.Values{
|
||||
"method": []string{"artist.getTopTracks"},
|
||||
"api_key": []string{"apiKey1"},
|
||||
"artist": []string{"artist1"},
|
||||
}, r.URL.Query())
|
||||
|
||||
require.Equal(t, "/2.0/", r.URL.Path)
|
||||
require.Equal(t, baseURL, "https://"+r.Host+r.URL.Path)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(mockclient.ArtistGetTopTracksResponse)
|
||||
})}
|
||||
|
||||
// act
|
||||
actual, err := client.ArtistGetTopTracks("apiKey1", "artist1")
|
||||
|
||||
// assert
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, TopTracks{
|
||||
Artist: "Artist 1",
|
||||
XMLName: xml.Name{
|
||||
Local: "toptracks",
|
||||
},
|
||||
Tracks: []Track{
|
||||
{
|
||||
Image: []Image{
|
||||
{
|
||||
Text: "https://last.fm/track-1-small.png",
|
||||
Size: "small",
|
||||
},
|
||||
{
|
||||
Text: "https://last.fm/track-1-large.png",
|
||||
Size: "large",
|
||||
},
|
||||
},
|
||||
Listeners: 2,
|
||||
MBID: "fdfc47cb-69d3-4318-ba71-d54fbc20169a",
|
||||
Name: "Track 1",
|
||||
PlayCount: 1,
|
||||
Rank: 1,
|
||||
URL: "https://www.last.fm/music/Artist+1/_/Track+1",
|
||||
},
|
||||
{
|
||||
Image: []Image{
|
||||
{
|
||||
Text: "https://last.fm/track-2-small.png",
|
||||
Size: "small",
|
||||
},
|
||||
{
|
||||
Text: "https://last.fm/track-2-large.png",
|
||||
Size: "large",
|
||||
},
|
||||
},
|
||||
Listeners: 3,
|
||||
MBID: "cf32e694-1ea6-4ba0-9e8b-d5f1950da9c8",
|
||||
Name: "Track 2",
|
||||
PlayCount: 2,
|
||||
Rank: 2,
|
||||
URL: "https://www.last.fm/music/Artist+1/_/Track+2",
|
||||
},
|
||||
},
|
||||
}, actual)
|
||||
}
|
||||
|
||||
func TestArtistGetTopTracks_clientRequestFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// arrange
|
||||
client := Client{mockclient.New(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, http.MethodGet, r.Method)
|
||||
require.Equal(t, url.Values{
|
||||
"method": []string{"artist.getTopTracks"},
|
||||
"api_key": []string{"apiKey1"},
|
||||
"artist": []string{"artist1"},
|
||||
}, r.URL.Query())
|
||||
|
||||
require.Equal(t, "/2.0/", r.URL.Path)
|
||||
require.Equal(t, baseURL, "https://"+r.Host+r.URL.Path)
|
||||
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
})}
|
||||
|
||||
// act
|
||||
actual, err := client.ArtistGetTopTracks("apiKey1", "artist1")
|
||||
|
||||
// assert
|
||||
require.Error(t, err)
|
||||
require.Zero(t, actual)
|
||||
}
|
||||
|
||||
func TestArtistGetSimilar(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// arrange
|
||||
client := Client{mockclient.New(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, http.MethodGet, r.Method)
|
||||
require.Equal(t, url.Values{
|
||||
"method": []string{"artist.getSimilar"},
|
||||
"api_key": []string{"apiKey1"},
|
||||
"artist": []string{"artist1"},
|
||||
}, r.URL.Query())
|
||||
|
||||
require.Equal(t, "/2.0/", r.URL.Path)
|
||||
require.Equal(t, baseURL, "https://"+r.Host+r.URL.Path)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(mockclient.ArtistGetSimilarResponse)
|
||||
})}
|
||||
|
||||
// act
|
||||
actual, err := client.ArtistGetSimilar("apiKey1", "artist1")
|
||||
|
||||
// assert
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, SimilarArtists{
|
||||
XMLName: xml.Name{
|
||||
Local: "similarartists",
|
||||
},
|
||||
Artist: "Artist 1",
|
||||
Artists: []Artist{
|
||||
{
|
||||
XMLName: xml.Name{
|
||||
Local: "artist",
|
||||
},
|
||||
Image: []Image{
|
||||
{
|
||||
Text: "https://last.fm/artist-2-small.png",
|
||||
Size: "small",
|
||||
},
|
||||
{
|
||||
Text: "https://last.fm/artist-2-large.png",
|
||||
Size: "large",
|
||||
},
|
||||
},
|
||||
MBID: "d2addad9-3fc4-4ce8-9cd4-63f2a19bb922",
|
||||
Name: "Artist 2",
|
||||
Similar: struct {
|
||||
Artists []Artist `xml:"artist"`
|
||||
}{},
|
||||
Streamable: "0",
|
||||
URL: "https://www.last.fm/music/Artist+2",
|
||||
},
|
||||
{
|
||||
XMLName: xml.Name{
|
||||
Local: "artist",
|
||||
},
|
||||
Image: []Image{
|
||||
{
|
||||
Text: "https://last.fm/artist-3-small.png",
|
||||
Size: "small",
|
||||
},
|
||||
{
|
||||
Text: "https://last.fm/artist-3-large.png",
|
||||
Size: "large",
|
||||
},
|
||||
},
|
||||
MBID: "dc95d067-df3e-4b83-a5fe-5ec773b1883f",
|
||||
Name: "Artist 3",
|
||||
Similar: struct {
|
||||
Artists []Artist `xml:"artist"`
|
||||
}{},
|
||||
Streamable: "0",
|
||||
URL: "https://www.last.fm/music/Artist+3",
|
||||
},
|
||||
},
|
||||
}, actual)
|
||||
}
|
||||
|
||||
func TestArtistGetSimilar_clientRequestFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// arrange
|
||||
client := Client{mockclient.New(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, http.MethodGet, r.Method)
|
||||
require.Equal(t, url.Values{
|
||||
"method": []string{"artist.getSimilar"},
|
||||
"api_key": []string{"apiKey1"},
|
||||
"artist": []string{"artist1"},
|
||||
}, r.URL.Query())
|
||||
|
||||
require.Equal(t, "/2.0/", r.URL.Path)
|
||||
require.Equal(t, baseURL, "https://"+r.Host+r.URL.Path)
|
||||
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
})}
|
||||
|
||||
// act
|
||||
actual, err := client.ArtistGetSimilar("apiKey1", "artist1")
|
||||
|
||||
// assert
|
||||
require.Error(t, err)
|
||||
require.Zero(t, actual)
|
||||
}
|
||||
|
||||
func TestTrackGetSimilarTracks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// arrange
|
||||
client := Client{mockclient.New(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, http.MethodGet, r.Method)
|
||||
require.Equal(t, url.Values{
|
||||
"method": []string{"track.getSimilar"},
|
||||
"api_key": []string{"apiKey1"},
|
||||
"artist": []string{"artist1"},
|
||||
"track": []string{"track1"},
|
||||
}, r.URL.Query())
|
||||
|
||||
require.Equal(t, "/2.0/", r.URL.Path)
|
||||
require.Equal(t, baseURL, "https://"+r.Host+r.URL.Path)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(mockclient.TrackGetSimilarResponse)
|
||||
})}
|
||||
|
||||
// act
|
||||
actual, err := client.TrackGetSimilarTracks("apiKey1", "artist1", "track1")
|
||||
|
||||
// assert
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, SimilarTracks{
|
||||
Artist: "Artist 1",
|
||||
Track: "Track 1",
|
||||
XMLName: xml.Name{
|
||||
Local: "similartracks",
|
||||
},
|
||||
Tracks: []Track{
|
||||
{
|
||||
Image: []Image{
|
||||
{
|
||||
Text: "https://last.fm/track-1-small.png",
|
||||
Size: "small",
|
||||
},
|
||||
{
|
||||
Text: "https://last.fm/track-1-large.png",
|
||||
Size: "large",
|
||||
},
|
||||
},
|
||||
MBID: "7096931c-bf82-4896-b1e7-42b60a0e16ea",
|
||||
Name: "Track 1",
|
||||
PlayCount: 1,
|
||||
URL: "https://www.last.fm/music/Artist+1/_/Track+1",
|
||||
},
|
||||
{
|
||||
Image: []Image{
|
||||
{
|
||||
Text: "https://last.fm/track-2-small.png",
|
||||
Size: "small",
|
||||
},
|
||||
{
|
||||
Text: "https://last.fm/track-2-large.png",
|
||||
Size: "large",
|
||||
},
|
||||
},
|
||||
MBID: "2aff1321-149f-4000-8762-3468c917600c",
|
||||
Name: "Track 2",
|
||||
PlayCount: 2,
|
||||
URL: "https://www.last.fm/music/Artist+2/_/Track+2",
|
||||
},
|
||||
},
|
||||
}, actual)
|
||||
}
|
||||
|
||||
func TestTrackGetSimilarTracks_clientRequestFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// arrange
|
||||
client := Client{mockclient.New(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, http.MethodGet, r.Method)
|
||||
require.Equal(t, url.Values{
|
||||
"method": []string{"track.getSimilar"},
|
||||
"api_key": []string{"apiKey1"},
|
||||
"artist": []string{"artist1"},
|
||||
"track": []string{"track1"},
|
||||
}, r.URL.Query())
|
||||
|
||||
require.Equal(t, "/2.0/", r.URL.Path)
|
||||
require.Equal(t, baseURL, "https://"+r.Host+r.URL.Path)
|
||||
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
})}
|
||||
|
||||
// act
|
||||
actual, err := client.TrackGetSimilarTracks("apiKey1", "artist1", "track1")
|
||||
|
||||
// assert
|
||||
require.Error(t, err)
|
||||
require.Zero(t, actual)
|
||||
}
|
||||
|
||||
func TestGetSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// arrange
|
||||
client := Client{mockclient.New(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, http.MethodGet, r.Method)
|
||||
require.Equal(t, url.Values{
|
||||
"method": []string{"auth.getSession"},
|
||||
"api_key": []string{"apiKey1"},
|
||||
"api_sig": []string{"b872a708a0b8b1d9fc1230b1cb6493f8"},
|
||||
"token": []string{"token1"},
|
||||
}, r.URL.Query())
|
||||
|
||||
require.Equal(t, "/2.0/", r.URL.Path)
|
||||
require.Equal(t, baseURL, "https://"+r.Host+r.URL.Path)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(mockclient.GetSessionResponse)
|
||||
})}
|
||||
|
||||
// act
|
||||
actual, err := client.GetSession("apiKey1", "secret1", "token1")
|
||||
|
||||
// assert
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "sessionKey1", actual)
|
||||
}
|
||||
|
||||
func TestGetSessioeClientRequestFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// arrange
|
||||
client := Client{mockclient.New(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, http.MethodGet, r.Method)
|
||||
require.Equal(t, url.Values{
|
||||
"method": []string{"auth.getSession"},
|
||||
"api_key": []string{"apiKey1"},
|
||||
"api_sig": []string{"b872a708a0b8b1d9fc1230b1cb6493f8"},
|
||||
"token": []string{"token1"},
|
||||
}, r.URL.Query())
|
||||
|
||||
require.Equal(t, "/2.0/", r.URL.Path)
|
||||
require.Equal(t, baseURL, "https://"+r.Host+r.URL.Path)
|
||||
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
})}
|
||||
|
||||
// act
|
||||
actual, err := client.GetSession("apiKey1", "secret1", "token1")
|
||||
|
||||
// assert
|
||||
require.Error(t, err)
|
||||
require.Zero(t, actual)
|
||||
}
|
||||
|
||||
func TestGetParamSignature(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
params := url.Values{}
|
||||
params.Add("ccc", "CCC")
|
||||
params.Add("bbb", "BBB")
|
||||
params.Add("aaa", "AAA")
|
||||
params.Add("ddd", "DDD")
|
||||
actual := getParamSignature(params, "secret")
|
||||
expected := fmt.Sprintf("%x", md5.Sum([]byte(
|
||||
"aaaAAAbbbBBBcccCCCdddDDDsecret",
|
||||
)))
|
||||
if actual != expected {
|
||||
t.Errorf("expected %x, got %s", expected, actual)
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<lfm status="ok">
|
||||
<artist>
|
||||
<name>Artist 1</name>
|
||||
<mbid>366c1119-ec4f-4312-b729-a5637d148e3e</mbid>
|
||||
<url>https://www.last.fm/music/Artist+1</url>
|
||||
<image size="small">https://last.fm/artist-1-small.png</image>
|
||||
<streamable>0</streamable>
|
||||
<ontour>0</ontour>
|
||||
<stats>
|
||||
<listeners>1</listeners>
|
||||
<playcount>2</playcount>
|
||||
</stats>
|
||||
<similar>
|
||||
<artist>
|
||||
<name>Similar Artist 1</name>
|
||||
<url>https://www.last.fm/music/Similar+Artist+1</url>
|
||||
<image size="small">https://last.fm/similar-artist-1-small.png</image>
|
||||
</artist>
|
||||
</similar>
|
||||
<tags>
|
||||
<tag>
|
||||
<name>tag1</name>
|
||||
<url>https://www.last.fm/tag/tag1</url>
|
||||
</tag>
|
||||
</tags>
|
||||
<bio>
|
||||
<links>
|
||||
<link rel="original" href="https://last.fm/music/Artist+1/+wiki"></link>
|
||||
</links>
|
||||
<published>13 May 2023, 00:24</published>
|
||||
<summary>Summary</summary>
|
||||
<content>Content</content>
|
||||
</bio>
|
||||
</artist>
|
||||
</lfm>
|
||||
@@ -1,23 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<lfm status="ok">
|
||||
<similarartists artist="Artist 1">
|
||||
<artist>
|
||||
<name>Artist 2</name>
|
||||
<mbid>d2addad9-3fc4-4ce8-9cd4-63f2a19bb922</mbid>
|
||||
<match>1</match>
|
||||
<url>https://www.last.fm/music/Artist+2</url>
|
||||
<image size="small">https://last.fm/artist-2-small.png</image>
|
||||
<image size="large">https://last.fm/artist-2-large.png</image>
|
||||
<streamable>0</streamable>
|
||||
</artist>
|
||||
<artist>
|
||||
<name>Artist 3</name>
|
||||
<mbid>dc95d067-df3e-4b83-a5fe-5ec773b1883f</mbid>
|
||||
<match>0.790991</match>
|
||||
<url>https://www.last.fm/music/Artist+3</url>
|
||||
<image size="small">https://last.fm/artist-3-small.png</image>
|
||||
<image size="large">https://last.fm/artist-3-large.png</image>
|
||||
<streamable>0</streamable>
|
||||
</artist>
|
||||
</similarartists>
|
||||
</lfm>
|
||||
@@ -1,35 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<lfm status="ok">
|
||||
<toptracks artist="Artist 1" page="1" perPage="50" totalPages="5" total="225">
|
||||
<track rank="1">
|
||||
<name>Track 1</name>
|
||||
<playcount>1</playcount>
|
||||
<listeners>2</listeners>
|
||||
<mbid>fdfc47cb-69d3-4318-ba71-d54fbc20169a</mbid>
|
||||
<url>https://www.last.fm/music/Artist+1/_/Track+1</url>
|
||||
<streamable>0</streamable>
|
||||
<artist>
|
||||
<name>Artist 1</name>
|
||||
<mbid>366c1119-ec4f-4312-b729-a5637d148e3e</mbid>
|
||||
<url>https://www.last.fm/music/Artist+1</url>
|
||||
</artist>
|
||||
<image size="small">https://last.fm/track-1-small.png</image>
|
||||
<image size="large">https://last.fm/track-1-large.png</image>
|
||||
</track>
|
||||
<track rank="2">
|
||||
<name>Track 2</name>
|
||||
<playcount>2</playcount>
|
||||
<listeners>3</listeners>
|
||||
<mbid>cf32e694-1ea6-4ba0-9e8b-d5f1950da9c8</mbid>
|
||||
<url>https://www.last.fm/music/Artist+1/_/Track+2</url>
|
||||
<streamable>0</streamable>
|
||||
<artist>
|
||||
<name>Artist 1</name>
|
||||
<mbid>366c1119-ec4f-4312-b729-a5637d148e3e</mbid>
|
||||
<url>https://www.last.fm/music/Artist+1</url>
|
||||
</artist>
|
||||
<image size="small">https://last.fm/track-2-small.png</image>
|
||||
<image size="large">https://last.fm/track-2-large.png</image>
|
||||
</track>
|
||||
</toptracks>
|
||||
</lfm>
|
||||
@@ -1,7 +0,0 @@
|
||||
<lfm status="ok">
|
||||
<session>
|
||||
<name>username1</name>
|
||||
<key>sessionKey1</key>
|
||||
<subscriber>0</subscriber>
|
||||
</session>
|
||||
</lfm>
|
||||
@@ -1,44 +0,0 @@
|
||||
package mockclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
_ "embed"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func New(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 artist_get_info_response.xml
|
||||
var ArtistGetInfoResponse []byte
|
||||
|
||||
//go:embed artist_get_top_tracks_response.xml
|
||||
var ArtistGetTopTracksResponse []byte
|
||||
|
||||
//go:embed artist_get_similar_response.xml
|
||||
var ArtistGetSimilarResponse []byte
|
||||
|
||||
//go:embed track_get_similar_response.xml
|
||||
var TrackGetSimilarResponse []byte
|
||||
|
||||
//go:embed get_session_response.xml
|
||||
var GetSessionResponse []byte
|
||||
@@ -1,37 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<lfm status="ok">
|
||||
<similartracks artist="Artist 1" track="Track 1">
|
||||
<track>
|
||||
<name>Track 1</name>
|
||||
<playcount>1</playcount>
|
||||
<mbid>7096931c-bf82-4896-b1e7-42b60a0e16ea</mbid>
|
||||
<match>1.000</match>
|
||||
<url>https://www.last.fm/music/Artist+1/_/Track+1</url>
|
||||
<streamable fulltrack="0">0</streamable>
|
||||
<duration>80</duration>
|
||||
<artist>
|
||||
<name>Artist+1</name>
|
||||
<mbid>366c1119-ec4f-4312-b729-a5637d148e3e</mbid>
|
||||
<url>https://www.last.fm/music/Artist+1</url>
|
||||
</artist>
|
||||
<image size="small">https://last.fm/track-1-small.png</image>
|
||||
<image size="large">https://last.fm/track-1-large.png</image>
|
||||
</track>
|
||||
<track>
|
||||
<name>Track 2</name>
|
||||
<playcount>2</playcount>
|
||||
<mbid>2aff1321-149f-4000-8762-3468c917600c</mbid>
|
||||
<match>0.422</match>
|
||||
<url>https://www.last.fm/music/Artist+2/_/Track+2</url>
|
||||
<streamable fulltrack="0">0</streamable>
|
||||
<duration>80</duration>
|
||||
<artist>
|
||||
<name>Artist+2</name>
|
||||
<mbid>9842b07f-956b-4c36-8ce1-884b4b96254d</mbid>
|
||||
<url>https://www.last.fm/music/Artist+1</url>
|
||||
</artist>
|
||||
<image size="small">https://last.fm/track-2-small.png</image>
|
||||
<image size="large">https://last.fm/track-2-large.png</image>
|
||||
</track>
|
||||
</similartracks>
|
||||
</lfm>
|
||||
@@ -1,142 +0,0 @@
|
||||
package lastfm
|
||||
|
||||
import "encoding/xml"
|
||||
|
||||
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"`
|
||||
TopTracks TopTracks `xml:"toptracks"`
|
||||
SimilarTracks SimilarTracks `xml:"similartracks"`
|
||||
SimilarArtists SimilarArtists `xml:"similarartists"`
|
||||
LovedTracks LovedTracks `xml:"lovedtracks"`
|
||||
User User `xml:"user"`
|
||||
}
|
||||
|
||||
Session struct {
|
||||
Name string `xml:"name"`
|
||||
Key string `xml:"key"`
|
||||
Subscriber uint `xml:"subscriber"`
|
||||
}
|
||||
|
||||
Error struct {
|
||||
Code uint `xml:"code,attr"`
|
||||
Value string `xml:",chardata"`
|
||||
}
|
||||
|
||||
SimilarArtist struct {
|
||||
XMLName xml.Name `xml:"artist"`
|
||||
Name string `xml:"name"`
|
||||
MBID string `xml:"mbid"`
|
||||
URL string `xml:"url"`
|
||||
Image []Image `xml:"image"`
|
||||
Streamable string `xml:"streamable"`
|
||||
}
|
||||
|
||||
Image struct {
|
||||
Text string `xml:",chardata"`
|
||||
Size string `xml:"size,attr"`
|
||||
}
|
||||
|
||||
Artist struct {
|
||||
XMLName xml.Name `xml:"artist"`
|
||||
Name string `xml:"name"`
|
||||
MBID string `xml:"mbid"`
|
||||
URL string `xml:"url"`
|
||||
Image []Image `xml:"image"`
|
||||
Streamable string `xml:"streamable"`
|
||||
Stats struct {
|
||||
Listeners string `xml:"listeners"`
|
||||
Playcount string `xml:"playcount"`
|
||||
} `xml:"stats"`
|
||||
Similar struct {
|
||||
Artists []Artist `xml:"artist"`
|
||||
} `xml:"similar"`
|
||||
Tags struct {
|
||||
Tag []ArtistTag `xml:"tag"`
|
||||
} `xml:"tags"`
|
||||
Bio ArtistBio `xml:"bio"`
|
||||
}
|
||||
|
||||
ArtistTag struct {
|
||||
Name string `xml:"name"`
|
||||
URL string `xml:"url"`
|
||||
}
|
||||
|
||||
ArtistBio struct {
|
||||
Published string `xml:"published"`
|
||||
Summary string `xml:"summary"`
|
||||
Content string `xml:"content"`
|
||||
}
|
||||
|
||||
TopTracks struct {
|
||||
XMLName xml.Name `xml:"toptracks"`
|
||||
Artist string `xml:"artist,attr"`
|
||||
Tracks []Track `xml:"track"`
|
||||
}
|
||||
|
||||
SimilarTracks struct {
|
||||
XMLName xml.Name `xml:"similartracks"`
|
||||
Artist string `xml:"artist,attr"`
|
||||
Track string `xml:"track,attr"`
|
||||
Tracks []Track `xml:"track"`
|
||||
}
|
||||
|
||||
SimilarArtists struct {
|
||||
XMLName xml.Name `xml:"similarartists"`
|
||||
Artist string `xml:"artist,attr"`
|
||||
Artists []Artist `xml:"artist"`
|
||||
}
|
||||
|
||||
Track struct {
|
||||
Rank int `xml:"rank,attr"`
|
||||
Tracks []Track `xml:"track"`
|
||||
Name string `xml:"name"`
|
||||
MBID string `xml:"mbid"`
|
||||
PlayCount int `xml:"playcount"`
|
||||
Listeners int `xml:"listeners"`
|
||||
URL string `xml:"url"`
|
||||
Image []Image `xml:"image"`
|
||||
}
|
||||
|
||||
LovedTracks struct {
|
||||
XMLName xml.Name `xml:"lovedtracks"`
|
||||
Tracks []struct {
|
||||
Track
|
||||
Date struct {
|
||||
Text string `xml:",chardata"`
|
||||
UTS string `xml:"uts,attr"`
|
||||
} `xml:"date"`
|
||||
Artist Artist `xml:"artist"`
|
||||
} `xml:"track"`
|
||||
}
|
||||
|
||||
User struct {
|
||||
Text string `xml:",chardata"`
|
||||
Name string `xml:"name"`
|
||||
Realname string `xml:"realname"`
|
||||
Image []struct {
|
||||
Text string `xml:",chardata"`
|
||||
Size string `xml:"size,attr"`
|
||||
} `xml:"image"`
|
||||
URL string `xml:"url"`
|
||||
Country string `xml:"country"`
|
||||
Age string `xml:"age"`
|
||||
Gender string `xml:"gender"`
|
||||
Subscriber string `xml:"subscriber"`
|
||||
Playcount string `xml:"playcount"`
|
||||
Playlists string `xml:"playlists"`
|
||||
Bootstrap string `xml:"bootstrap"`
|
||||
Registered struct {
|
||||
Text string `xml:",chardata"`
|
||||
Unixtime string `xml:"unixtime,attr"`
|
||||
} `xml:"registered"`
|
||||
Type string `xml:"type"`
|
||||
ArtistCount string `xml:"artist_count"`
|
||||
AlbumCount string `xml:"album_count"`
|
||||
TrackCount string `xml:"track_count"`
|
||||
}
|
||||
)
|
||||
@@ -1,128 +0,0 @@
|
||||
package lastfm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.senan.xyz/gonic/db"
|
||||
"go.senan.xyz/gonic/scrobble"
|
||||
)
|
||||
|
||||
type Scrobbler struct {
|
||||
db *db.DB
|
||||
*Client
|
||||
}
|
||||
|
||||
var _ scrobble.Scrobbler = (*Scrobbler)(nil)
|
||||
|
||||
// TODO: remove dependency on db here
|
||||
func NewScrobbler(db *db.DB, client *Client) *Scrobbler {
|
||||
return &Scrobbler{
|
||||
db: db,
|
||||
Client: client,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scrobbler) Scrobble(user *db.User, track *db.Track, stamp time.Time, submission bool) error {
|
||||
if user.LastFMSession == "" {
|
||||
return nil
|
||||
}
|
||||
if track.Album == nil || len(track.Album.Artists) == 0 {
|
||||
return fmt.Errorf("track has no album artists")
|
||||
}
|
||||
|
||||
apiKey, err := s.db.GetSetting(db.LastFMAPIKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get api key: %w", err)
|
||||
}
|
||||
secret, err := s.db.GetSetting(db.LastFMSecret)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get secret: %w", err)
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
if submission {
|
||||
params.Add("method", "track.Scrobble")
|
||||
// last.fm wants the timestamp in seconds
|
||||
params.Add("timestamp", strconv.Itoa(int(stamp.Unix())))
|
||||
} else {
|
||||
params.Add("method", "track.updateNowPlaying")
|
||||
}
|
||||
|
||||
params.Add("api_key", apiKey)
|
||||
params.Add("sk", user.LastFMSession)
|
||||
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("albumArtist", strings.Join(track.Album.ArtistsStrings(), ", "))
|
||||
params.Add("duration", strconv.Itoa(track.Length))
|
||||
|
||||
// make sure we provide a valid uuid, since some users may have an incorrect mbid in their tags
|
||||
if _, err := uuid.Parse(track.TagBrainzID); err == nil {
|
||||
params.Add("mbid", track.TagBrainzID)
|
||||
}
|
||||
|
||||
params.Add("api_sig", getParamSignature(params, secret))
|
||||
|
||||
_, err = s.Client.makeRequest(http.MethodPost, params)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Scrobbler) LoveTrack(user *db.User, track *db.Track) error {
|
||||
if user.LastFMSession == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
apiKey, err := s.db.GetSetting(db.LastFMAPIKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get api key: %w", err)
|
||||
}
|
||||
secret, err := s.db.GetSetting(db.LastFMSecret)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get secret: %w", err)
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Add("method", "track.love")
|
||||
params.Add("track", track.TagTitle)
|
||||
params.Add("artist", track.TagTrackArtist)
|
||||
params.Add("api_key", apiKey)
|
||||
params.Add("sk", user.LastFMSession)
|
||||
params.Add("api_sig", getParamSignature(params, secret))
|
||||
|
||||
_, err = s.makeRequest(http.MethodPost, params)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Scrobbler) GetCurrentUser(user *db.User) (User, error) {
|
||||
if user.LastFMSession == "" {
|
||||
return User{}, nil
|
||||
}
|
||||
|
||||
apiKey, err := s.db.GetSetting(db.LastFMAPIKey)
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("get api key: %w", err)
|
||||
}
|
||||
secret, err := s.db.GetSetting(db.LastFMSecret)
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("get secret: %w", err)
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Add("method", "user.getInfo")
|
||||
params.Add("api_key", apiKey)
|
||||
params.Add("sk", user.LastFMSession)
|
||||
params.Add("api_sig", getParamSignature(params, secret))
|
||||
|
||||
resp, err := s.makeRequest(http.MethodGet, params)
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("making user GET: %w", err)
|
||||
}
|
||||
return resp.User, nil
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
package lastfm
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.senan.xyz/gonic/db"
|
||||
"go.senan.xyz/gonic/scrobble/lastfm/mockclient"
|
||||
)
|
||||
|
||||
func TestScrobble(t *testing.T) {
|
||||
// arrange
|
||||
t.Parallel()
|
||||
|
||||
testDB, err := db.NewMock()
|
||||
require.NoError(t, err)
|
||||
err = testDB.Migrate(db.MigrationContext{})
|
||||
require.NoError(t, err)
|
||||
|
||||
testDB.SetSetting(db.LastFMAPIKey, "apiKey1")
|
||||
testDB.SetSetting(db.LastFMSecret, "secret1")
|
||||
|
||||
user := &db.User{
|
||||
LastFMSession: "lastFMSession1",
|
||||
}
|
||||
|
||||
track := &db.Track{
|
||||
Album: &db.Album{
|
||||
TagTitle: "album1",
|
||||
Artists: []*db.Artist{{
|
||||
Name: "artist1",
|
||||
}},
|
||||
},
|
||||
Length: 100,
|
||||
TagBrainzID: "916b242d-d439-4ae4-a439-556eef99c06e",
|
||||
TagTitle: "title1",
|
||||
TagTrackArtist: "trackArtist1",
|
||||
TagTrackNumber: 1,
|
||||
}
|
||||
|
||||
stamp := time.Date(2023, 8, 12, 12, 34, 1, 200, time.UTC)
|
||||
|
||||
client := Client{mockclient.New(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, http.MethodPost, r.Method)
|
||||
require.Equal(t, url.Values{
|
||||
"album": []string{"album1"},
|
||||
"albumArtist": []string{"artist1"},
|
||||
"api_key": []string{"apiKey1"},
|
||||
"api_sig": []string{"d235a0b911eb4923953f496c61a2a6af"},
|
||||
"artist": []string{"trackArtist1"},
|
||||
"duration": []string{"100"},
|
||||
"method": []string{"track.Scrobble"},
|
||||
"sk": []string{"lastFMSession1"},
|
||||
"mbid": []string{"916b242d-d439-4ae4-a439-556eef99c06e"},
|
||||
"timestamp": []string{"1691843641"},
|
||||
"track": []string{"title1"},
|
||||
"trackNumber": []string{"1"},
|
||||
}, r.URL.Query())
|
||||
|
||||
require.Equal(t, "/2.0/", r.URL.Path)
|
||||
require.Equal(t, baseURL, "https://"+r.Host+r.URL.Path)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(mockclient.ArtistGetTopTracksResponse)
|
||||
})}
|
||||
|
||||
scrobbler := NewScrobbler(testDB, &client)
|
||||
|
||||
// act
|
||||
err = scrobbler.Scrobble(user, track, stamp, true)
|
||||
|
||||
// assert
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestScrobbleReturnsWithoutLastFMSession(t *testing.T) {
|
||||
// arrange
|
||||
t.Parallel()
|
||||
|
||||
scrobbler := Scrobbler{}
|
||||
|
||||
// act
|
||||
err := scrobbler.Scrobble(&db.User{}, &db.Track{}, time.Now(), false)
|
||||
|
||||
// assert
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestScrobbleFailsWithoutLastFMAPIKey(t *testing.T) {
|
||||
// arrange
|
||||
t.Parallel()
|
||||
|
||||
testDB, err := db.NewMock()
|
||||
require.NoError(t, err)
|
||||
|
||||
user := &db.User{
|
||||
LastFMSession: "lastFMSession1",
|
||||
}
|
||||
|
||||
scrobbler := NewScrobbler(testDB, nil)
|
||||
|
||||
// act
|
||||
err = scrobbler.Scrobble(user, &db.Track{}, time.Now(), false)
|
||||
|
||||
// assert
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
package listenbrainz
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.senan.xyz/gonic/db"
|
||||
"go.senan.xyz/gonic/scrobble"
|
||||
)
|
||||
|
||||
const (
|
||||
BaseURL = "https://api.listenbrainz.org"
|
||||
|
||||
submitPath = "/1/submit-listens"
|
||||
listenTypeSingle = "single"
|
||||
listenTypePlayingNow = "playing_now"
|
||||
)
|
||||
|
||||
var ErrListenBrainz = errors.New("listenbrainz error")
|
||||
|
||||
type Scrobbler struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewScrobbler() *Scrobbler {
|
||||
return &Scrobbler{
|
||||
httpClient: http.DefaultClient,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scrobbler) Scrobble(user *db.User, track *db.Track, stamp time.Time, submission bool) error {
|
||||
if user.ListenBrainzURL == "" || user.ListenBrainzToken == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 := s.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
|
||||
}
|
||||
|
||||
var _ scrobble.Scrobbler = (*Scrobbler)(nil)
|
||||
@@ -1,145 +0,0 @@
|
||||
package listenbrainz
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
_ "embed"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.senan.xyz/gonic/db"
|
||||
)
|
||||
|
||||
func httpClientMock(handler http.Handler) (http.Client, func()) {
|
||||
server := httptest.NewTLSServer(handler)
|
||||
shutdown := 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
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return shutdown, server.Close
|
||||
}
|
||||
|
||||
//go:embed testdata/submit_listens_request.json
|
||||
var submitListensRequest string
|
||||
|
||||
func TestScrobble(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// arrange
|
||||
client, shutdown := httpClientMock(http.HandlerFunc(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}`))
|
||||
}))
|
||||
defer shutdown()
|
||||
|
||||
scrobbler := Scrobbler{
|
||||
httpClient: &client,
|
||||
}
|
||||
|
||||
// act
|
||||
err := scrobbler.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)
|
||||
|
||||
// assert
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestScrobbleUnauthorized(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// arrange
|
||||
client, shutdown := httpClientMock(http.HandlerFunc(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."}`))
|
||||
}))
|
||||
defer shutdown()
|
||||
|
||||
scrobbler := Scrobbler{
|
||||
httpClient: &client,
|
||||
}
|
||||
|
||||
// act
|
||||
err := scrobbler.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)
|
||||
|
||||
// assert
|
||||
require.ErrorIs(t, err, ErrListenBrainz)
|
||||
}
|
||||
|
||||
func TestScrobbleServerError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// arrange
|
||||
client, shutdown := httpClientMock(http.HandlerFunc(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)
|
||||
}))
|
||||
defer shutdown()
|
||||
|
||||
scrobbler := Scrobbler{
|
||||
httpClient: &client,
|
||||
}
|
||||
|
||||
// act
|
||||
err := scrobbler.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)
|
||||
|
||||
// assert
|
||||
require.ErrorIs(t, err, ErrListenBrainz)
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
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"`
|
||||
}
|
||||
)
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"listen_type": "single",
|
||||
"payload": [
|
||||
{
|
||||
"listened_at": 1683804525,
|
||||
"track_metadata": {
|
||||
"additional_info": {
|
||||
"tracknumber": 1
|
||||
},
|
||||
"artist_name": "artist",
|
||||
"track_name": "title",
|
||||
"release_name": "album"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package scrobble
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.senan.xyz/gonic/db"
|
||||
)
|
||||
|
||||
type Scrobbler interface {
|
||||
Scrobble(user *db.User, track *db.Track, stamp time.Time, submission bool) error
|
||||
}
|
||||
Reference in New Issue
Block a user