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:
@@ -26,13 +26,12 @@ import (
|
|||||||
"go.senan.xyz/gonic"
|
"go.senan.xyz/gonic"
|
||||||
"go.senan.xyz/gonic/db"
|
"go.senan.xyz/gonic/db"
|
||||||
"go.senan.xyz/gonic/jukebox"
|
"go.senan.xyz/gonic/jukebox"
|
||||||
|
"go.senan.xyz/gonic/lastfm"
|
||||||
|
"go.senan.xyz/gonic/listenbrainz"
|
||||||
"go.senan.xyz/gonic/playlist"
|
"go.senan.xyz/gonic/playlist"
|
||||||
"go.senan.xyz/gonic/podcasts"
|
"go.senan.xyz/gonic/podcasts"
|
||||||
"go.senan.xyz/gonic/scanner"
|
"go.senan.xyz/gonic/scanner"
|
||||||
"go.senan.xyz/gonic/scanner/tags"
|
"go.senan.xyz/gonic/scanner/tags"
|
||||||
"go.senan.xyz/gonic/scrobble"
|
|
||||||
"go.senan.xyz/gonic/scrobble/lastfm"
|
|
||||||
"go.senan.xyz/gonic/scrobble/listenbrainz"
|
|
||||||
"go.senan.xyz/gonic/server/ctrladmin"
|
"go.senan.xyz/gonic/server/ctrladmin"
|
||||||
"go.senan.xyz/gonic/server/ctrlbase"
|
"go.senan.xyz/gonic/server/ctrlbase"
|
||||||
"go.senan.xyz/gonic/server/ctrlsubsonic"
|
"go.senan.xyz/gonic/server/ctrlsubsonic"
|
||||||
@@ -180,7 +179,19 @@ func main() {
|
|||||||
transcode.NewFFmpegTranscoder(),
|
transcode.NewFFmpegTranscoder(),
|
||||||
cacheDirAudio,
|
cacheDirAudio,
|
||||||
)
|
)
|
||||||
lastfmClient := lastfm.NewClient()
|
|
||||||
|
lastfmClientKeySecretFunc := func() (string, string, error) {
|
||||||
|
apiKey, _ := dbc.GetSetting(db.LastFMAPIKey)
|
||||||
|
secret, _ := dbc.GetSetting(db.LastFMSecret)
|
||||||
|
if apiKey == "" || secret == "" {
|
||||||
|
return "", "", fmt.Errorf("not configured")
|
||||||
|
}
|
||||||
|
return apiKey, secret, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
listenbrainzClient := listenbrainz.NewClient()
|
||||||
|
lastfmClient := lastfm.NewClient(lastfmClientKeySecretFunc)
|
||||||
|
|
||||||
playlistStore, err := playlist.NewStore(*confPlaylistsPath)
|
playlistStore, err := playlist.NewStore(*confPlaylistsPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Panicf("error creating playlists store: %v", err)
|
log.Panicf("error creating playlists store: %v", err)
|
||||||
@@ -224,9 +235,9 @@ func main() {
|
|||||||
CacheCoverPath: cacheDirCovers,
|
CacheCoverPath: cacheDirCovers,
|
||||||
LastFMClient: lastfmClient,
|
LastFMClient: lastfmClient,
|
||||||
ArtistInfoCache: artistInfoCache,
|
ArtistInfoCache: artistInfoCache,
|
||||||
Scrobblers: []scrobble.Scrobbler{
|
Scrobblers: []ctrlsubsonic.Scrobbler{
|
||||||
lastfm.NewScrobbler(dbc, lastfmClient),
|
lastfmClient,
|
||||||
listenbrainz.NewScrobbler(),
|
listenbrainzClient,
|
||||||
},
|
},
|
||||||
Podcasts: podcast,
|
Podcasts: podcast,
|
||||||
Transcoder: transcoder,
|
Transcoder: transcoder,
|
||||||
@@ -349,11 +360,10 @@ func main() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
lastfmAPIKey, _ := dbc.GetSetting(db.LastFMAPIKey)
|
if _, _, err := lastfmClientKeySecretFunc(); err == nil {
|
||||||
if lastfmAPIKey != "" {
|
|
||||||
g.Add(func() error {
|
g.Add(func() error {
|
||||||
log.Printf("starting job 'refresh artist info'\n")
|
log.Printf("starting job 'refresh artist info'\n")
|
||||||
return artistInfoCache.Refresh(lastfmAPIKey, 8*time.Second)
|
return artistInfoCache.Refresh(8 * time.Second)
|
||||||
}, noCleanup)
|
}, noCleanup)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
312
lastfm/client.go
Normal file
312
lastfm/client.go
Normal 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
583
lastfm/client_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"go.senan.xyz/gonic/db"
|
"go.senan.xyz/gonic/db"
|
||||||
"go.senan.xyz/gonic/scrobble"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -25,21 +24,23 @@ const (
|
|||||||
|
|
||||||
var ErrListenBrainz = errors.New("listenbrainz error")
|
var ErrListenBrainz = errors.New("listenbrainz error")
|
||||||
|
|
||||||
type Scrobbler struct {
|
type Client struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewScrobbler() *Scrobbler {
|
func NewClient() *Client {
|
||||||
return &Scrobbler{
|
return NewClientCustom(http.DefaultClient)
|
||||||
httpClient: http.DefaultClient,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Scrobbler) Scrobble(user *db.User, track *db.Track, stamp time.Time, submission bool) error {
|
func NewClientCustom(httpClient *http.Client) *Client {
|
||||||
if user.ListenBrainzURL == "" || user.ListenBrainzToken == "" {
|
return &Client{httpClient: httpClient}
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// make sure we provide a valid uuid, since some users may have an incorrect mbid in their tags
|
||||||
var trackMBID string
|
var trackMBID string
|
||||||
if _, err := uuid.Parse(track.TagBrainzID); err == nil {
|
if _, err := uuid.Parse(track.TagBrainzID); err == nil {
|
||||||
@@ -61,6 +62,7 @@ func (s *Scrobbler) Scrobble(user *db.User, track *db.Track, stamp time.Time, su
|
|||||||
scrobble := Scrobble{
|
scrobble := Scrobble{
|
||||||
Payload: []*Payload{payload},
|
Payload: []*Payload{payload},
|
||||||
}
|
}
|
||||||
|
|
||||||
if submission && len(scrobble.Payload) > 0 {
|
if submission && len(scrobble.Payload) > 0 {
|
||||||
scrobble.ListenType = listenTypeSingle
|
scrobble.ListenType = listenTypeSingle
|
||||||
scrobble.Payload[0].ListenedAt = int(stamp.Unix())
|
scrobble.Payload[0].ListenedAt = int(stamp.Unix())
|
||||||
@@ -72,12 +74,15 @@ func (s *Scrobbler) Scrobble(user *db.User, track *db.Track, stamp time.Time, su
|
|||||||
if err := json.NewEncoder(&payloadBuf).Encode(scrobble); err != nil {
|
if err := json.NewEncoder(&payloadBuf).Encode(scrobble); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
submitURL := fmt.Sprintf("%s%s", user.ListenBrainzURL, submitPath)
|
submitURL := fmt.Sprintf("%s%s", user.ListenBrainzURL, submitPath)
|
||||||
authHeader := fmt.Sprintf("Token %s", user.ListenBrainzToken)
|
authHeader := fmt.Sprintf("Token %s", user.ListenBrainzToken)
|
||||||
|
|
||||||
req, _ := http.NewRequest(http.MethodPost, submitURL, &payloadBuf)
|
req, _ := http.NewRequest(http.MethodPost, submitURL, &payloadBuf)
|
||||||
req.Header.Add("Content-Type", "application/json")
|
req.Header.Add("Content-Type", "application/json")
|
||||||
req.Header.Add("Authorization", authHeader)
|
req.Header.Add("Authorization", authHeader)
|
||||||
resp, err := s.httpClient.Do(req)
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("http post: %w", err)
|
return fmt.Errorf("http post: %w", err)
|
||||||
}
|
}
|
||||||
@@ -93,5 +98,3 @@ func (s *Scrobbler) Scrobble(user *db.User, track *db.Track, stamp time.Time, su
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ scrobble.Scrobbler = (*Scrobbler)(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
|
||||||
@@ -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,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,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,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
|
|
||||||
}
|
|
||||||
@@ -22,8 +22,8 @@ import (
|
|||||||
|
|
||||||
"go.senan.xyz/gonic"
|
"go.senan.xyz/gonic"
|
||||||
"go.senan.xyz/gonic/db"
|
"go.senan.xyz/gonic/db"
|
||||||
|
"go.senan.xyz/gonic/lastfm"
|
||||||
"go.senan.xyz/gonic/podcasts"
|
"go.senan.xyz/gonic/podcasts"
|
||||||
"go.senan.xyz/gonic/scrobble/lastfm"
|
|
||||||
"go.senan.xyz/gonic/server/ctrladmin/adminui"
|
"go.senan.xyz/gonic/server/ctrladmin/adminui"
|
||||||
"go.senan.xyz/gonic/server/ctrlbase"
|
"go.senan.xyz/gonic/server/ctrlbase"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ import (
|
|||||||
"github.com/nfnt/resize"
|
"github.com/nfnt/resize"
|
||||||
|
|
||||||
"go.senan.xyz/gonic/db"
|
"go.senan.xyz/gonic/db"
|
||||||
|
"go.senan.xyz/gonic/listenbrainz"
|
||||||
"go.senan.xyz/gonic/scanner"
|
"go.senan.xyz/gonic/scanner"
|
||||||
"go.senan.xyz/gonic/scrobble/listenbrainz"
|
|
||||||
"go.senan.xyz/gonic/transcode"
|
"go.senan.xyz/gonic/transcode"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -97,15 +97,7 @@ func (c *Controller) ServeLinkLastFMDo(r *http.Request) *Response {
|
|||||||
if token == "" {
|
if token == "" {
|
||||||
return &Response{code: 400, err: "please provide a token"}
|
return &Response{code: 400, err: "please provide a token"}
|
||||||
}
|
}
|
||||||
apiKey, err := c.DB.GetSetting(db.LastFMAPIKey)
|
sessionKey, err := c.lastfmClient.GetSession(token)
|
||||||
if err != nil {
|
|
||||||
return &Response{redirect: r.Referer(), flashW: []string{fmt.Sprintf("couldn't get api key: %v", err)}}
|
|
||||||
}
|
|
||||||
secret, err := c.DB.GetSetting(db.LastFMSecret)
|
|
||||||
if err != nil {
|
|
||||||
return &Response{redirect: r.Referer(), flashW: []string{fmt.Sprintf("couldn't get secret: %v", err)}}
|
|
||||||
}
|
|
||||||
sessionKey, err := c.lastfmClient.GetSession(apiKey, secret, token)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &Response{
|
return &Response{
|
||||||
redirect: "/admin/home",
|
redirect: "/admin/home",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
|
|
||||||
"github.com/jinzhu/gorm"
|
"github.com/jinzhu/gorm"
|
||||||
"go.senan.xyz/gonic/db"
|
"go.senan.xyz/gonic/db"
|
||||||
"go.senan.xyz/gonic/scrobble/lastfm"
|
"go.senan.xyz/gonic/lastfm"
|
||||||
)
|
)
|
||||||
|
|
||||||
const keepFor = 30 * time.Hour * 24
|
const keepFor = 30 * time.Hour * 24
|
||||||
@@ -24,7 +24,7 @@ func New(db *db.DB, lastfmClient *lastfm.Client) *ArtistInfoCache {
|
|||||||
return &ArtistInfoCache{db: db, lastfmClient: lastfmClient}
|
return &ArtistInfoCache{db: db, lastfmClient: lastfmClient}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *ArtistInfoCache) GetOrLookup(ctx context.Context, apiKey string, artistID int) (*db.ArtistInfo, error) {
|
func (a *ArtistInfoCache) GetOrLookup(ctx context.Context, artistID int) (*db.ArtistInfo, error) {
|
||||||
var artist db.Artist
|
var artist db.Artist
|
||||||
if err := a.db.Find(&artist, "id=?", artistID).Error; err != nil {
|
if err := a.db.Find(&artist, "id=?", artistID).Error; err != nil {
|
||||||
return nil, fmt.Errorf("find artist in db: %w", err)
|
return nil, fmt.Errorf("find artist in db: %w", err)
|
||||||
@@ -36,7 +36,7 @@ func (a *ArtistInfoCache) GetOrLookup(ctx context.Context, apiKey string, artist
|
|||||||
}
|
}
|
||||||
|
|
||||||
if artistInfo.ID == 0 || artistInfo.Biography == "" /* prev not found maybe */ || time.Since(artistInfo.UpdatedAt) > keepFor {
|
if artistInfo.ID == 0 || artistInfo.Biography == "" /* prev not found maybe */ || time.Since(artistInfo.UpdatedAt) > keepFor {
|
||||||
return a.Lookup(ctx, apiKey, &artist)
|
return a.Lookup(ctx, &artist)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &artistInfo, nil
|
return &artistInfo, nil
|
||||||
@@ -50,7 +50,7 @@ func (a *ArtistInfoCache) Get(ctx context.Context, artistID int) (*db.ArtistInfo
|
|||||||
return &artistInfo, nil
|
return &artistInfo, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *ArtistInfoCache) Lookup(ctx context.Context, apiKey string, artist *db.Artist) (*db.ArtistInfo, error) {
|
func (a *ArtistInfoCache) Lookup(ctx context.Context, artist *db.Artist) (*db.ArtistInfo, error) {
|
||||||
var artistInfo db.ArtistInfo
|
var artistInfo db.ArtistInfo
|
||||||
artistInfo.ID = artist.ID
|
artistInfo.ID = artist.ID
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ func (a *ArtistInfoCache) Lookup(ctx context.Context, apiKey string, artist *db.
|
|||||||
return nil, fmt.Errorf("first or create artist info: %w", err)
|
return nil, fmt.Errorf("first or create artist info: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
info, err := a.lastfmClient.ArtistGetInfo(apiKey, artist.Name)
|
info, err := a.lastfmClient.ArtistGetInfo(artist.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get upstream info: %w", err)
|
return nil, fmt.Errorf("get upstream info: %w", err)
|
||||||
}
|
}
|
||||||
@@ -77,7 +77,7 @@ func (a *ArtistInfoCache) Lookup(ctx context.Context, apiKey string, artist *db.
|
|||||||
url, _ := a.lastfmClient.StealArtistImage(info.URL)
|
url, _ := a.lastfmClient.StealArtistImage(info.URL)
|
||||||
artistInfo.ImageURL = url
|
artistInfo.ImageURL = url
|
||||||
|
|
||||||
topTracksResponse, err := a.lastfmClient.ArtistGetTopTracks(apiKey, artist.Name)
|
topTracksResponse, err := a.lastfmClient.ArtistGetTopTracks(artist.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get top tracks: %w", err)
|
return nil, fmt.Errorf("get top tracks: %w", err)
|
||||||
}
|
}
|
||||||
@@ -94,7 +94,7 @@ func (a *ArtistInfoCache) Lookup(ctx context.Context, apiKey string, artist *db.
|
|||||||
return &artistInfo, nil
|
return &artistInfo, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *ArtistInfoCache) Refresh(apiKey string, interval time.Duration) error {
|
func (a *ArtistInfoCache) Refresh(interval time.Duration) error {
|
||||||
ticker := time.NewTicker(interval)
|
ticker := time.NewTicker(interval)
|
||||||
for range ticker.C {
|
for range ticker.C {
|
||||||
q := a.db.
|
q := a.db.
|
||||||
@@ -110,7 +110,7 @@ func (a *ArtistInfoCache) Refresh(apiKey string, interval time.Duration) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := a.Lookup(context.Background(), apiKey, &artist); err != nil {
|
if _, err := a.Lookup(context.Background(), &artist); err != nil {
|
||||||
log.Printf("error looking up non cached artist %s: %v", artist.Name, err)
|
log.Printf("error looking up non cached artist %s: %v", artist.Name, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import (
|
|||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go.senan.xyz/gonic/db"
|
"go.senan.xyz/gonic/db"
|
||||||
|
"go.senan.xyz/gonic/lastfm"
|
||||||
|
"go.senan.xyz/gonic/lastfm/mockclient"
|
||||||
"go.senan.xyz/gonic/mockfs"
|
"go.senan.xyz/gonic/mockfs"
|
||||||
"go.senan.xyz/gonic/scrobble/lastfm"
|
|
||||||
"go.senan.xyz/gonic/scrobble/lastfm/mockclient"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestInfoCache(t *testing.T) {
|
func TestInfoCache(t *testing.T) {
|
||||||
@@ -29,7 +29,8 @@ func TestInfoCache(t *testing.T) {
|
|||||||
assert.Nil(artist.Info)
|
assert.Nil(artist.Info)
|
||||||
|
|
||||||
var count atomic.Int32
|
var count atomic.Int32
|
||||||
lastfmClient := lastfm.NewClientCustom(mockclient.New(t, func(w http.ResponseWriter, r *http.Request) {
|
lastfmClient := lastfm.NewClientCustom(
|
||||||
|
mockclient.New(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
switch method := r.URL.Query().Get("method"); method {
|
switch method := r.URL.Query().Get("method"); method {
|
||||||
case "artist.getInfo":
|
case "artist.getInfo":
|
||||||
count.Add(1)
|
count.Add(1)
|
||||||
@@ -37,12 +38,16 @@ func TestInfoCache(t *testing.T) {
|
|||||||
case "artist.getTopTracks":
|
case "artist.getTopTracks":
|
||||||
w.Write(mockclient.ArtistGetTopTracksResponse)
|
w.Write(mockclient.ArtistGetTopTracksResponse)
|
||||||
}
|
}
|
||||||
}))
|
}),
|
||||||
|
func() (apiKey string, secret string, err error) {
|
||||||
|
return "", "", nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
cache := New(m.DB(), lastfmClient)
|
cache := New(m.DB(), lastfmClient)
|
||||||
_, err := cache.GetOrLookup(context.Background(), "", artist.ID)
|
_, err := cache.GetOrLookup(context.Background(), artist.ID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
_, err = cache.GetOrLookup(context.Background(), "", artist.ID)
|
_, err = cache.GetOrLookup(context.Background(), artist.ID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
require.Equal(t, int32(1), count.Load())
|
require.Equal(t, int32(1), count.Load())
|
||||||
|
|||||||
@@ -7,11 +7,12 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.senan.xyz/gonic/db"
|
||||||
"go.senan.xyz/gonic/jukebox"
|
"go.senan.xyz/gonic/jukebox"
|
||||||
|
"go.senan.xyz/gonic/lastfm"
|
||||||
"go.senan.xyz/gonic/podcasts"
|
"go.senan.xyz/gonic/podcasts"
|
||||||
"go.senan.xyz/gonic/scrobble"
|
|
||||||
"go.senan.xyz/gonic/scrobble/lastfm"
|
|
||||||
"go.senan.xyz/gonic/server/ctrlbase"
|
"go.senan.xyz/gonic/server/ctrlbase"
|
||||||
"go.senan.xyz/gonic/server/ctrlsubsonic/artistinfocache"
|
"go.senan.xyz/gonic/server/ctrlsubsonic/artistinfocache"
|
||||||
"go.senan.xyz/gonic/server/ctrlsubsonic/params"
|
"go.senan.xyz/gonic/server/ctrlsubsonic/params"
|
||||||
@@ -39,6 +40,11 @@ func PathsOf(paths []MusicPath) []string {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Scrobbler interface {
|
||||||
|
IsUserAuthenticated(user *db.User) bool
|
||||||
|
Scrobble(user *db.User, track *db.Track, stamp time.Time, submission bool) error
|
||||||
|
}
|
||||||
|
|
||||||
type Controller struct {
|
type Controller struct {
|
||||||
*ctrlbase.Controller
|
*ctrlbase.Controller
|
||||||
MusicPaths []MusicPath
|
MusicPaths []MusicPath
|
||||||
@@ -46,7 +52,7 @@ type Controller struct {
|
|||||||
CacheAudioPath string
|
CacheAudioPath string
|
||||||
CacheCoverPath string
|
CacheCoverPath string
|
||||||
Jukebox *jukebox.Jukebox
|
Jukebox *jukebox.Jukebox
|
||||||
Scrobblers []scrobble.Scrobbler
|
Scrobblers []Scrobbler
|
||||||
Podcasts *podcasts.Podcasts
|
Podcasts *podcasts.Podcasts
|
||||||
Transcoder transcode.Transcoder
|
Transcoder transcode.Transcoder
|
||||||
LastFMClient *lastfm.Client
|
LastFMClient *lastfm.Client
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package ctrlsubsonic
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -318,14 +319,10 @@ func (c *Controller) ServeGetArtistInfoTwo(r *http.Request) *spec.Response {
|
|||||||
sub := spec.NewResponse()
|
sub := spec.NewResponse()
|
||||||
sub.ArtistInfoTwo = &spec.ArtistInfo{}
|
sub.ArtistInfoTwo = &spec.ArtistInfo{}
|
||||||
|
|
||||||
apiKey, _ := c.DB.GetSetting(db.LastFMAPIKey)
|
info, err := c.ArtistInfoCache.GetOrLookup(r.Context(), artist.ID)
|
||||||
if apiKey == "" {
|
|
||||||
return sub
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := c.ArtistInfoCache.GetOrLookup(r.Context(), apiKey, artist.ID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return spec.NewError(0, "fetching artist info: %v", err)
|
log.Printf("error fetching artist info from lastfm: %v", err)
|
||||||
|
return sub
|
||||||
}
|
}
|
||||||
|
|
||||||
sub.ArtistInfoTwo.Biography = info.Biography
|
sub.ArtistInfoTwo.Biography = info.Biography
|
||||||
@@ -541,13 +538,10 @@ func (c *Controller) ServeGetTopSongs(r *http.Request) *spec.Response {
|
|||||||
return spec.NewError(0, "finding artist by name: %v", err)
|
return spec.NewError(0, "finding artist by name: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
apiKey, _ := c.DB.GetSetting(db.LastFMAPIKey)
|
info, err := c.ArtistInfoCache.GetOrLookup(r.Context(), artist.ID)
|
||||||
if apiKey == "" {
|
|
||||||
return spec.NewResponse()
|
|
||||||
}
|
|
||||||
info, err := c.ArtistInfoCache.GetOrLookup(r.Context(), apiKey, artist.ID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return spec.NewError(0, "fetching artist top tracks: %v", err)
|
log.Printf("error fetching artist info from lastfm: %v", err)
|
||||||
|
return spec.NewResponse()
|
||||||
}
|
}
|
||||||
|
|
||||||
sub := spec.NewResponse()
|
sub := spec.NewResponse()
|
||||||
@@ -597,10 +591,6 @@ func (c *Controller) ServeGetSimilarSongs(r *http.Request) *spec.Response {
|
|||||||
if err != nil || id.Type != specid.Track {
|
if err != nil || id.Type != specid.Track {
|
||||||
return spec.NewError(10, "please provide an track `id` parameter")
|
return spec.NewError(10, "please provide an track `id` parameter")
|
||||||
}
|
}
|
||||||
apiKey, _ := c.DB.GetSetting(db.LastFMAPIKey)
|
|
||||||
if apiKey == "" {
|
|
||||||
return spec.NewResponse()
|
|
||||||
}
|
|
||||||
|
|
||||||
var track db.Track
|
var track db.Track
|
||||||
err = c.DB.
|
err = c.DB.
|
||||||
@@ -612,10 +602,12 @@ func (c *Controller) ServeGetSimilarSongs(r *http.Request) *spec.Response {
|
|||||||
return spec.NewError(10, "couldn't find a track with that id")
|
return spec.NewError(10, "couldn't find a track with that id")
|
||||||
}
|
}
|
||||||
|
|
||||||
similarTracks, err := c.LastFMClient.TrackGetSimilarTracks(apiKey, track.TagTrackArtist, track.TagTitle)
|
similarTracks, err := c.LastFMClient.TrackGetSimilarTracks(track.TagTrackArtist, track.TagTitle)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return spec.NewError(0, "fetching track similar tracks: %v", err)
|
log.Printf("error fetching similar songs from lastfm: %v", err)
|
||||||
|
return spec.NewResponse()
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(similarTracks.Tracks) == 0 {
|
if len(similarTracks.Tracks) == 0 {
|
||||||
return spec.NewError(70, "no similar songs found for track: %v", track.TagTitle)
|
return spec.NewError(70, "no similar songs found for track: %v", track.TagTitle)
|
||||||
}
|
}
|
||||||
@@ -666,11 +658,6 @@ func (c *Controller) ServeGetSimilarSongsTwo(r *http.Request) *spec.Response {
|
|||||||
return spec.NewError(10, "please provide an artist `id` parameter")
|
return spec.NewError(10, "please provide an artist `id` parameter")
|
||||||
}
|
}
|
||||||
|
|
||||||
apiKey, _ := c.DB.GetSetting(db.LastFMAPIKey)
|
|
||||||
if apiKey == "" {
|
|
||||||
return spec.NewResponse()
|
|
||||||
}
|
|
||||||
|
|
||||||
var artist db.Artist
|
var artist db.Artist
|
||||||
err = c.DB.
|
err = c.DB.
|
||||||
Where("id=?", id.Value).
|
Where("id=?", id.Value).
|
||||||
@@ -680,9 +667,10 @@ func (c *Controller) ServeGetSimilarSongsTwo(r *http.Request) *spec.Response {
|
|||||||
return spec.NewError(0, "artist with id `%s` not found", id)
|
return spec.NewError(0, "artist with id `%s` not found", id)
|
||||||
}
|
}
|
||||||
|
|
||||||
similarArtists, err := c.LastFMClient.ArtistGetSimilar(apiKey, artist.Name)
|
similarArtists, err := c.LastFMClient.ArtistGetSimilar(artist.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return spec.NewError(0, "fetching artist similar artists: %v", err)
|
log.Printf("error fetching artist info from lastfm: %v", err)
|
||||||
|
return spec.NewResponse()
|
||||||
}
|
}
|
||||||
if len(similarArtists.Artists) == 0 {
|
if len(similarArtists.Artists) == 0 {
|
||||||
return spec.NewError(0, "no similar artist found for: %v", artist.Name)
|
return spec.NewError(0, "no similar artist found for: %v", artist.Name)
|
||||||
|
|||||||
@@ -74,6 +74,9 @@ func (c *Controller) ServeScrobble(r *http.Request) *spec.Response {
|
|||||||
|
|
||||||
var scrobbleErrs []error
|
var scrobbleErrs []error
|
||||||
for _, scrobbler := range c.Scrobblers {
|
for _, scrobbler := range c.Scrobblers {
|
||||||
|
if !scrobbler.IsUserAuthenticated(user) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if err := scrobbler.Scrobble(user, track, optStamp, optSubmission); err != nil {
|
if err := scrobbler.Scrobble(user, track, optStamp, optSubmission); err != nil {
|
||||||
scrobbleErrs = append(scrobbleErrs, err)
|
scrobbleErrs = append(scrobbleErrs, err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user