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

312
lastfm/client.go Normal file
View File

@@ -0,0 +1,312 @@
package lastfm
import (
"crypto/md5"
"encoding/hex"
"encoding/xml"
"errors"
"fmt"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
"github.com/andybalholm/cascadia"
"github.com/google/uuid"
"go.senan.xyz/gonic/db"
"golang.org/x/net/html"
)
var (
ErrLastFM = errors.New("last.fm error")
ErrNoUserSession = errors.New("no lastfm user session present")
)
type KeySecretFunc func() (apiKey, secret string, err error)
type Client struct {
httpClient *http.Client
keySecret KeySecretFunc
}
func NewClient(keySecret KeySecretFunc) *Client {
return NewClientCustom(http.DefaultClient, keySecret)
}
func NewClientCustom(httpClient *http.Client, keySecret KeySecretFunc) *Client {
return &Client{httpClient: httpClient, keySecret: keySecret}
}
const (
BaseURL = "https://ws.audioscrobbler.com/2.0/"
)
func (c *Client) ArtistGetInfo(artistName string) (Artist, error) {
apiKey, _, err := c.keySecret()
if err != nil {
return Artist{}, fmt.Errorf("get key and secret: %w", err)
}
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("make request: %w", err)
}
return resp.Artist, nil
}
func (c *Client) ArtistGetTopTracks(artistName string) (TopTracks, error) {
apiKey, _, err := c.keySecret()
if err != nil {
return TopTracks{}, fmt.Errorf("get key and secret: %w", err)
}
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("make request: %w", err)
}
return resp.TopTracks, nil
}
func (c *Client) TrackGetSimilarTracks(artistName, trackName string) (SimilarTracks, error) {
apiKey, _, err := c.keySecret()
if err != nil {
return SimilarTracks{}, fmt.Errorf("get key and secret: %w", err)
}
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("make request: %w", err)
}
return resp.SimilarTracks, nil
}
func (c *Client) ArtistGetSimilar(artistName string) (SimilarArtists, error) {
apiKey, _, err := c.keySecret()
if err != nil {
return SimilarArtists{}, fmt.Errorf("get key and secret: %w", err)
}
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(userName string) (LovedTracks, error) {
apiKey, _, err := c.keySecret()
if err != nil {
return LovedTracks{}, fmt.Errorf("get key and secret: %w", err)
}
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(token string) (string, error) {
apiKey, secret, err := c.keySecret()
if err != nil {
return "", fmt.Errorf("get key and secret: %w", err)
}
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("make request: %w", err)
}
return resp.Session.Key, nil
}
//nolint:gochecknoglobals
var artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`)
func (c *Client) StealArtistImage(artistURL string) (string, error) {
resp, err := c.httpClient.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
}
func (c *Client) IsUserAuthenticated(user *db.User) bool {
return user.LastFMSession != ""
}
func (c *Client) Scrobble(user *db.User, track *db.Track, stamp time.Time, submission bool) error {
apiKey, secret, err := c.keySecret()
if err != nil {
return fmt.Errorf("get key and secret: %w", err)
}
if !c.IsUserAuthenticated(user) {
return ErrNoUserSession
}
if track.Album == nil || len(track.Album.Artists) == 0 {
return fmt.Errorf("track has no album artists")
}
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 = c.makeRequest(http.MethodPost, params)
return err
}
func (c *Client) LoveTrack(user *db.User, track *db.Track) error {
apiKey, secret, err := c.keySecret()
if err != nil {
return fmt.Errorf("get key and secret: %w", err)
}
if !c.IsUserAuthenticated(user) {
return ErrNoUserSession
}
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 = c.makeRequest(http.MethodPost, params)
return err
}
func (c *Client) GetCurrentUser(user *db.User) (User, error) {
apiKey, secret, err := c.keySecret()
if err != nil {
return User{}, fmt.Errorf("get key and secret: %w", err)
}
if !c.IsUserAuthenticated(user) {
return User{}, ErrNoUserSession
}
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 := c.makeRequest(http.MethodGet, params)
if err != nil {
return User{}, fmt.Errorf("make request: %w", err)
}
return resp.User, nil
}
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()
var lastfm LastFM
if err = xml.NewDecoder(resp.Body).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 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[:])
}

583
lastfm/client_test.go Normal file
View File

@@ -0,0 +1,583 @@
//nolint:goconst
package lastfm_test
import (
"crypto/md5"
"encoding/xml"
"fmt"
"net/http"
"net/url"
"testing"
"time"
"github.com/stretchr/testify/require"
"go.senan.xyz/gonic/db"
"go.senan.xyz/gonic/lastfm"
"go.senan.xyz/gonic/lastfm/mockclient"
)
func TestArtistGetInfo(t *testing.T) {
t.Parallel()
client := lastfm.NewClientCustom(
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, lastfm.BaseURL, "https://"+r.Host+r.URL.Path)
w.WriteHeader(http.StatusOK)
w.Write(mockclient.ArtistGetInfoResponse)
}),
func() (string, string, error) {
return "apiKey1", "", nil
},
)
actual, err := client.ArtistGetInfo("Artist 1")
require.NoError(t, err)
require.Equal(t, lastfm.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: []lastfm.Image{
{
Size: "small",
Text: "https://last.fm/artist-1-small.png",
},
},
Bio: lastfm.ArtistBio{
Published: "13 May 2023, 00:24",
Summary: "Summary",
Content: "Content",
},
Similar: struct {
Artists []lastfm.Artist `xml:"artist"`
}{
Artists: []lastfm.Artist{
{
XMLName: xml.Name{
Local: "artist",
},
Name: "Similar Artist 1",
URL: "https://www.last.fm/music/Similar+Artist+1",
Image: []lastfm.Image{
{
Size: "small",
Text: "https://last.fm/similar-artist-1-small.png",
},
},
},
},
},
Tags: struct {
Tag []lastfm.ArtistTag `xml:"tag"`
}{
Tag: []lastfm.ArtistTag{
{
Name: "tag1",
URL: "https://www.last.fm/tag/tag1",
},
},
},
}, actual)
}
func TestArtistGetInfoClientRequestFails(t *testing.T) {
t.Parallel()
client := lastfm.NewClientCustom(
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, lastfm.BaseURL, "https://"+r.Host+r.URL.Path)
w.WriteHeader(http.StatusInternalServerError)
}),
func() (string, string, error) {
return "apiKey1", "", nil
},
)
actual, err := client.ArtistGetInfo("Artist 1")
require.Error(t, err)
require.Zero(t, actual)
}
func TestArtistGetTopTracks(t *testing.T) {
t.Parallel()
client := lastfm.NewClientCustom(
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, lastfm.BaseURL, "https://"+r.Host+r.URL.Path)
w.WriteHeader(http.StatusOK)
w.Write(mockclient.ArtistGetTopTracksResponse)
}),
func() (string, string, error) {
return "apiKey1", "", nil
},
)
actual, err := client.ArtistGetTopTracks("artist1")
require.NoError(t, err)
require.Equal(t, lastfm.TopTracks{
Artist: "Artist 1",
XMLName: xml.Name{
Local: "toptracks",
},
Tracks: []lastfm.Track{
{
Image: []lastfm.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: []lastfm.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 TestArtistGetTopTracksClientRequestFails(t *testing.T) {
t.Parallel()
client := lastfm.NewClientCustom(
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, lastfm.BaseURL, "https://"+r.Host+r.URL.Path)
w.WriteHeader(http.StatusInternalServerError)
}),
func() (string, string, error) {
return "apiKey1", "", nil
},
)
actual, err := client.ArtistGetTopTracks("artist1")
require.Error(t, err)
require.Zero(t, actual)
}
func TestArtistGetSimilar(t *testing.T) {
t.Parallel()
client := lastfm.NewClientCustom(
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, lastfm.BaseURL, "https://"+r.Host+r.URL.Path)
w.WriteHeader(http.StatusOK)
w.Write(mockclient.ArtistGetSimilarResponse)
}),
func() (string, string, error) {
return "apiKey1", "", nil
},
)
actual, err := client.ArtistGetSimilar("artist1")
require.NoError(t, err)
require.Equal(t, lastfm.SimilarArtists{
XMLName: xml.Name{
Local: "similarartists",
},
Artist: "Artist 1",
Artists: []lastfm.Artist{
{
XMLName: xml.Name{
Local: "artist",
},
Image: []lastfm.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 []lastfm.Artist `xml:"artist"`
}{},
Streamable: "0",
URL: "https://www.last.fm/music/Artist+2",
},
{
XMLName: xml.Name{
Local: "artist",
},
Image: []lastfm.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 []lastfm.Artist `xml:"artist"`
}{},
Streamable: "0",
URL: "https://www.last.fm/music/Artist+3",
},
},
}, actual)
}
func TestArtistGetSimilarClientRequestFails(t *testing.T) {
t.Parallel()
client := lastfm.NewClientCustom(
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, lastfm.BaseURL, "https://"+r.Host+r.URL.Path)
w.WriteHeader(http.StatusInternalServerError)
}),
func() (string, string, error) {
return "apiKey1", "", nil
},
)
actual, err := client.ArtistGetSimilar("artist1")
require.Error(t, err)
require.Zero(t, actual)
}
func TestTrackGetSimilarTracks(t *testing.T) {
t.Parallel()
client := lastfm.NewClientCustom(
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, lastfm.BaseURL, "https://"+r.Host+r.URL.Path)
w.WriteHeader(http.StatusOK)
w.Write(mockclient.TrackGetSimilarResponse)
}),
func() (string, string, error) {
return "apiKey1", "", nil
},
)
actual, err := client.TrackGetSimilarTracks("artist1", "track1")
require.NoError(t, err)
require.Equal(t, lastfm.SimilarTracks{
Artist: "Artist 1",
Track: "Track 1",
XMLName: xml.Name{
Local: "similartracks",
},
Tracks: []lastfm.Track{
{
Image: []lastfm.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: []lastfm.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 TestTrackGetSimilarTracksClientRequestFails(t *testing.T) {
t.Parallel()
client := lastfm.NewClientCustom(
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, lastfm.BaseURL, "https://"+r.Host+r.URL.Path)
w.WriteHeader(http.StatusInternalServerError)
}),
func() (string, string, error) {
return "apiKey1", "", nil
},
)
actual, err := client.TrackGetSimilarTracks("artist1", "track1")
require.Error(t, err)
require.Zero(t, actual)
}
func TestGetSession(t *testing.T) {
t.Parallel()
client := lastfm.NewClientCustom(
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, lastfm.BaseURL, "https://"+r.Host+r.URL.Path)
w.WriteHeader(http.StatusOK)
w.Write(mockclient.GetSessionResponse)
}),
func() (string, string, error) {
return "apiKey1", "secret1", nil
},
)
actual, err := client.GetSession("token1")
require.NoError(t, err)
require.Equal(t, "sessionKey1", actual)
}
func TestGetSessionClientRequestFails(t *testing.T) {
t.Parallel()
client := lastfm.NewClientCustom(
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, lastfm.BaseURL, "https://"+r.Host+r.URL.Path)
w.WriteHeader(http.StatusInternalServerError)
}),
func() (string, string, error) {
return "apiKey1", "secret1", nil
},
)
actual, err := client.GetSession("token1")
require.Error(t, err)
require.Zero(t, actual)
}
func TestScrobble(t *testing.T) {
t.Parallel()
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 := lastfm.NewClientCustom(
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, lastfm.BaseURL, "https://"+r.Host+r.URL.Path)
w.WriteHeader(http.StatusOK)
w.Write(mockclient.ArtistGetTopTracksResponse)
}),
func() (apiKey string, secret string, err error) {
return "apiKey1", "secret1", nil
},
)
err := client.Scrobble(user, track, stamp, true)
require.NoError(t, err)
}
func TestScrobbleErrorsWithoutLastFMSession(t *testing.T) {
t.Parallel()
client := lastfm.NewClient(func() (apiKey string, secret string, err error) {
return "", "", nil
})
err := client.Scrobble(&db.User{}, &db.Track{}, time.Now(), false)
require.ErrorIs(t, err, lastfm.ErrNoUserSession)
}
func TestScrobbleFailsWithoutLastFMAPIKey(t *testing.T) {
t.Parallel()
user := &db.User{
LastFMSession: "lastFMSession1",
}
scrobbler := lastfm.NewClient(func() (string, string, error) {
return "", "", fmt.Errorf("no keys")
})
err := scrobbler.Scrobble(user, &db.Track{}, time.Now(), false)
require.Error(t, err)
}
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 := lastfm.GetParamSignature(params, "secret")
expected := fmt.Sprintf("%x", md5.Sum([]byte(
"aaaAAAbbbBBBcccCCCdddDDDsecret",
)))
if actual != expected {
t.Errorf("expected %x, got %s", expected, actual)
}
}

View File

@@ -0,0 +1,36 @@
<?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>

View File

@@ -0,0 +1,23 @@
<?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>

View File

@@ -0,0 +1,35 @@
<?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>

View File

@@ -0,0 +1,7 @@
<lfm status="ok">
<session>
<name>username1</name>
<key>sessionKey1</key>
<subscriber>0</subscriber>
</session>
</lfm>

View File

@@ -0,0 +1,44 @@
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

View File

@@ -0,0 +1,37 @@
<?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>

142
lastfm/model.go Normal file
View File

@@ -0,0 +1,142 @@
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"`
}
)