Skip to content

Commit dbcccdc

Browse files
authored
add initial Last.FM tests (#329)
* Move model into separate file * Separate Last.FM client and scrobbler * Use separate Last.FM client and scrobbler * Fix playcount attribute name * Add initial test for Last.FM client
1 parent 6144ac7 commit dbcccdc

File tree

12 files changed

+572
-359
lines changed

12 files changed

+572
-359
lines changed

scrobble/lastfm/client.go

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
package lastfm
2+
3+
import (
4+
"crypto/md5"
5+
"encoding/hex"
6+
"encoding/xml"
7+
"errors"
8+
"fmt"
9+
"log"
10+
"net/http"
11+
"net/http/httputil"
12+
"net/url"
13+
"sort"
14+
15+
"github.com/andybalholm/cascadia"
16+
"golang.org/x/net/html"
17+
)
18+
19+
const (
20+
baseURL = "https://ws.audioscrobbler.com/2.0/"
21+
)
22+
23+
var (
24+
ErrLastFM = errors.New("last.fm error")
25+
26+
//nolint:gochecknoglobals
27+
artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`)
28+
)
29+
30+
type Client struct {
31+
httpClient *http.Client
32+
}
33+
34+
func NewClient() *Client {
35+
return &Client{
36+
httpClient: http.DefaultClient,
37+
}
38+
}
39+
40+
func getParamSignature(params url.Values, secret string) string {
41+
// the parameters must be in order before hashing
42+
paramKeys := make([]string, 0, len(params))
43+
for k := range params {
44+
paramKeys = append(paramKeys, k)
45+
}
46+
sort.Strings(paramKeys)
47+
toHash := ""
48+
for _, k := range paramKeys {
49+
toHash += k
50+
toHash += params[k][0]
51+
}
52+
toHash += secret
53+
hash := md5.Sum([]byte(toHash))
54+
return hex.EncodeToString(hash[:])
55+
}
56+
57+
func (c *Client) makeRequest(method string, params url.Values) (LastFM, error) {
58+
req, _ := http.NewRequest(method, baseURL, nil)
59+
req.URL.RawQuery = params.Encode()
60+
resp, err := c.httpClient.Do(req)
61+
if err != nil {
62+
return LastFM{}, fmt.Errorf("get: %w", err)
63+
}
64+
defer resp.Body.Close()
65+
decoder := xml.NewDecoder(resp.Body)
66+
lastfm := LastFM{}
67+
if err = decoder.Decode(&lastfm); err != nil {
68+
respBytes, _ := httputil.DumpResponse(resp, true)
69+
log.Printf("received bad lastfm response:\n%s", string(respBytes))
70+
return LastFM{}, fmt.Errorf("decoding: %w", err)
71+
}
72+
if lastfm.Error.Code != 0 {
73+
respBytes, _ := httputil.DumpResponse(resp, true)
74+
log.Printf("received bad lastfm response:\n%s", string(respBytes))
75+
return LastFM{}, fmt.Errorf("%v: %w", lastfm.Error.Value, ErrLastFM)
76+
}
77+
return lastfm, nil
78+
}
79+
80+
func (c *Client) ArtistGetInfo(apiKey string, artistName string) (Artist, error) {
81+
params := url.Values{}
82+
params.Add("method", "artist.getInfo")
83+
params.Add("api_key", apiKey)
84+
params.Add("artist", artistName)
85+
resp, err := c.makeRequest("GET", params)
86+
if err != nil {
87+
return Artist{}, fmt.Errorf("making artist GET: %w", err)
88+
}
89+
return resp.Artist, nil
90+
}
91+
92+
func (c *Client) ArtistGetTopTracks(apiKey, artistName string) (TopTracks, error) {
93+
params := url.Values{}
94+
params.Add("method", "artist.getTopTracks")
95+
params.Add("api_key", apiKey)
96+
params.Add("artist", artistName)
97+
resp, err := c.makeRequest("GET", params)
98+
if err != nil {
99+
return TopTracks{}, fmt.Errorf("making track GET: %w", err)
100+
}
101+
return resp.TopTracks, nil
102+
}
103+
104+
func (c *Client) TrackGetSimilarTracks(apiKey string, artistName, trackName string) (SimilarTracks, error) {
105+
params := url.Values{}
106+
params.Add("method", "track.getSimilar")
107+
params.Add("api_key", apiKey)
108+
params.Add("track", trackName)
109+
params.Add("artist", artistName)
110+
resp, err := c.makeRequest("GET", params)
111+
if err != nil {
112+
return SimilarTracks{}, fmt.Errorf("making track GET: %w", err)
113+
}
114+
return resp.SimilarTracks, nil
115+
}
116+
117+
func (c *Client) ArtistGetSimilar(apiKey string, artistName string) (SimilarArtists, error) {
118+
params := url.Values{}
119+
params.Add("method", "artist.getSimilar")
120+
params.Add("api_key", apiKey)
121+
params.Add("artist", artistName)
122+
resp, err := c.makeRequest("GET", params)
123+
if err != nil {
124+
return SimilarArtists{}, fmt.Errorf("making similar artists GET: %w", err)
125+
}
126+
return resp.SimilarArtists, nil
127+
}
128+
129+
func (c *Client) GetSession(apiKey, secret, token string) (string, error) {
130+
params := url.Values{}
131+
params.Add("method", "auth.getSession")
132+
params.Add("api_key", apiKey)
133+
params.Add("token", token)
134+
params.Add("api_sig", getParamSignature(params, secret))
135+
resp, err := c.makeRequest("GET", params)
136+
if err != nil {
137+
return "", fmt.Errorf("making session GET: %w", err)
138+
}
139+
return resp.Session.Key, nil
140+
}
141+
142+
func (c *Client) StealArtistImage(artistURL string) (string, error) {
143+
resp, err := http.Get(artistURL) //nolint:gosec
144+
if err != nil {
145+
return "", fmt.Errorf("get artist url: %w", err)
146+
}
147+
defer resp.Body.Close()
148+
149+
node, err := html.Parse(resp.Body)
150+
if err != nil {
151+
return "", fmt.Errorf("parse html: %w", err)
152+
}
153+
154+
n := cascadia.Query(node, artistOpenGraphQuery)
155+
if n == nil {
156+
return "", nil
157+
}
158+
159+
var imageURL string
160+
for _, attr := range n.Attr {
161+
if attr.Key == "content" {
162+
imageURL = attr.Val
163+
break
164+
}
165+
}
166+
167+
return imageURL, nil
168+
}

scrobble/lastfm/client_test.go

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
package lastfm
2+
3+
import (
4+
"context"
5+
"crypto/md5"
6+
"crypto/tls"
7+
_ "embed"
8+
"encoding/xml"
9+
"fmt"
10+
"net"
11+
"net/http"
12+
"net/http/httptest"
13+
"net/url"
14+
"testing"
15+
16+
"github.com/stretchr/testify/require"
17+
)
18+
19+
func httpClientMock(handler http.Handler) (http.Client, func()) {
20+
server := httptest.NewTLSServer(handler)
21+
client := http.Client{
22+
Transport: &http.Transport{
23+
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
24+
return net.Dial(network, server.Listener.Addr().String())
25+
},
26+
TLSClientConfig: &tls.Config{
27+
InsecureSkipVerify: true, //nolint:gosec
28+
},
29+
},
30+
}
31+
32+
return client, server.Close
33+
}
34+
35+
//go:embed testdata/artist_get_info_response.xml
36+
var artistGetInfoResponse string
37+
38+
func TestArtistGetInfo(t *testing.T) {
39+
// arrange
40+
require := require.New(t)
41+
httpClient, shutdown := httpClientMock(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
42+
require.Equal(http.MethodGet, r.Method)
43+
require.Equal(url.Values{
44+
"method": []string{"artist.getInfo"},
45+
"api_key": []string{"apiKey1"},
46+
"artist": []string{"Artist 1"},
47+
}, r.URL.Query())
48+
require.Equal("/2.0/", r.URL.Path)
49+
require.Equal(baseURL, "https://"+r.Host+r.URL.Path)
50+
51+
w.WriteHeader(http.StatusOK)
52+
w.Write([]byte(artistGetInfoResponse))
53+
}))
54+
defer shutdown()
55+
56+
client := Client{&httpClient}
57+
58+
// act
59+
actual, err := client.ArtistGetInfo("apiKey1", "Artist 1")
60+
61+
// assert
62+
require.NoError(err)
63+
require.Equal(Artist{
64+
XMLName: xml.Name{
65+
Local: "artist",
66+
},
67+
Name: "Artist 1",
68+
MBID: "366c1119-ec4f-4312-b729-a5637d148e3e",
69+
Streamable: "0",
70+
Stats: struct {
71+
Listeners string `xml:"listeners"`
72+
Playcount string `xml:"playcount"`
73+
}{
74+
Listeners: "1",
75+
Playcount: "2",
76+
},
77+
URL: "https://www.last.fm/music/Artist+1",
78+
Image: []ArtistImage{
79+
{
80+
Size: "small",
81+
Text: "https://last.fm/artist-1-small.png",
82+
},
83+
},
84+
Bio: ArtistBio{
85+
Published: "13 May 2023, 00:24",
86+
Summary: "Summary",
87+
Content: "Content",
88+
},
89+
Similar: struct {
90+
Artists []Artist `xml:"artist"`
91+
}{
92+
Artists: []Artist{
93+
{
94+
XMLName: xml.Name{
95+
Local: "artist",
96+
},
97+
Name: "Similar Artist 1",
98+
URL: "https://www.last.fm/music/Similar+Artist+1",
99+
Image: []ArtistImage{
100+
{
101+
Size: "small",
102+
Text: "https://last.fm/similar-artist-1-small.png",
103+
},
104+
},
105+
},
106+
},
107+
},
108+
Tags: struct {
109+
Tag []ArtistTag `xml:"tag"`
110+
}{
111+
Tag: []ArtistTag{
112+
{
113+
Name: "tag1",
114+
URL: "https://www.last.fm/tag/tag1",
115+
},
116+
},
117+
},
118+
}, actual)
119+
}
120+
121+
func TestArtistGetInfo_clientRequestFails(t *testing.T) {
122+
// arrange
123+
require := require.New(t)
124+
httpClient, shutdown := httpClientMock(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
125+
require.Equal(http.MethodGet, r.Method)
126+
require.Equal(url.Values{
127+
"method": []string{"artist.getInfo"},
128+
"api_key": []string{"apiKey1"},
129+
"artist": []string{"Artist 1"},
130+
}, r.URL.Query())
131+
require.Equal("/2.0/", r.URL.Path)
132+
require.Equal(baseURL, "https://"+r.Host+r.URL.Path)
133+
134+
w.WriteHeader(http.StatusInternalServerError)
135+
}))
136+
defer shutdown()
137+
138+
client := Client{&httpClient}
139+
140+
// act
141+
actual, err := client.ArtistGetInfo("apiKey1", "Artist 1")
142+
143+
// assert
144+
require.Error(err)
145+
require.Zero(actual)
146+
}
147+
148+
func TestGetParamSignature(t *testing.T) {
149+
params := url.Values{}
150+
params.Add("ccc", "CCC")
151+
params.Add("bbb", "BBB")
152+
params.Add("aaa", "AAA")
153+
params.Add("ddd", "DDD")
154+
actual := getParamSignature(params, "secret")
155+
expected := fmt.Sprintf("%x", md5.Sum([]byte(
156+
"aaaAAAbbbBBBcccCCCdddDDDsecret",
157+
)))
158+
if actual != expected {
159+
t.Errorf("expected %x, got %s", expected, actual)
160+
}
161+
}

0 commit comments

Comments
 (0)