From dbcccdc811b7a4b158b0505dbc9c1e3855696bd2 Mon Sep 17 00:00:00 2001 From: Gregor Zurowski Date: Sat, 20 May 2023 13:24:03 +0200 Subject: [PATCH] add initial Last.FM tests (#329) * Move model into separate file * Separate Last.FM client and scrobbler * Use separate Last.FM client and scrobbler * Fix playcount attribute name * Add initial test for Last.FM client --- scrobble/lastfm/client.go | 168 ++++++++++ scrobble/lastfm/client_test.go | 161 +++++++++ scrobble/lastfm/lastfm.go | 313 ------------------ scrobble/lastfm/lastfm_test.go | 23 -- scrobble/lastfm/model.go | 108 ++++++ scrobble/lastfm/scrobbler.go | 67 ++++ .../testdata/artist_get_info_response.xml | 36 ++ server/ctrladmin/ctrl.go | 23 +- server/ctrladmin/handlers.go | 3 +- server/ctrlsubsonic/ctrl.go | 2 + server/ctrlsubsonic/handlers_by_tags.go | 13 +- server/server.go | 14 +- 12 files changed, 572 insertions(+), 359 deletions(-) create mode 100644 scrobble/lastfm/client.go create mode 100644 scrobble/lastfm/client_test.go delete mode 100644 scrobble/lastfm/lastfm.go delete mode 100644 scrobble/lastfm/lastfm_test.go create mode 100644 scrobble/lastfm/model.go create mode 100644 scrobble/lastfm/scrobbler.go create mode 100644 scrobble/lastfm/testdata/artist_get_info_response.xml diff --git a/scrobble/lastfm/client.go b/scrobble/lastfm/client.go new file mode 100644 index 0000000..d747141 --- /dev/null +++ b/scrobble/lastfm/client.go @@ -0,0 +1,168 @@ +package lastfm + +import ( + "crypto/md5" + "encoding/hex" + "encoding/xml" + "errors" + "fmt" + "log" + "net/http" + "net/http/httputil" + "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 NewClient() *Client { + return &Client{ + httpClient: 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 { + respBytes, _ := httputil.DumpResponse(resp, true) + log.Printf("received bad lastfm response:\n%s", string(respBytes)) + return LastFM{}, fmt.Errorf("decoding: %w", err) + } + if lastfm.Error.Code != 0 { + respBytes, _ := httputil.DumpResponse(resp, true) + log.Printf("received bad lastfm response:\n%s", string(respBytes)) + 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("GET", 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("GET", 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("GET", 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("GET", params) + if err != nil { + return SimilarArtists{}, fmt.Errorf("making similar artists GET: %w", err) + } + return resp.SimilarArtists, 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("GET", 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 +} diff --git a/scrobble/lastfm/client_test.go b/scrobble/lastfm/client_test.go new file mode 100644 index 0000000..0a1de7e --- /dev/null +++ b/scrobble/lastfm/client_test.go @@ -0,0 +1,161 @@ +package lastfm + +import ( + "context" + "crypto/md5" + "crypto/tls" + _ "embed" + "encoding/xml" + "fmt" + "net" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func httpClientMock(handler http.Handler) (http.Client, func()) { + server := httptest.NewTLSServer(handler) + client := 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 client, server.Close +} + +//go:embed testdata/artist_get_info_response.xml +var artistGetInfoResponse string + +func TestArtistGetInfo(t *testing.T) { + // arrange + require := require.New(t) + httpClient, shutdown := httpClientMock(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(http.MethodGet, r.Method) + require.Equal(url.Values{ + "method": []string{"artist.getInfo"}, + "api_key": []string{"apiKey1"}, + "artist": []string{"Artist 1"}, + }, r.URL.Query()) + require.Equal("/2.0/", r.URL.Path) + require.Equal(baseURL, "https://"+r.Host+r.URL.Path) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(artistGetInfoResponse)) + })) + defer shutdown() + + client := Client{&httpClient} + + // act + actual, err := client.ArtistGetInfo("apiKey1", "Artist 1") + + // assert + require.NoError(err) + require.Equal(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: []ArtistImage{ + { + 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: []ArtistImage{ + { + 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 TestArtistGetInfo_clientRequestFails(t *testing.T) { + // arrange + require := require.New(t) + httpClient, shutdown := httpClientMock(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(http.MethodGet, r.Method) + require.Equal(url.Values{ + "method": []string{"artist.getInfo"}, + "api_key": []string{"apiKey1"}, + "artist": []string{"Artist 1"}, + }, r.URL.Query()) + require.Equal("/2.0/", r.URL.Path) + require.Equal(baseURL, "https://"+r.Host+r.URL.Path) + + w.WriteHeader(http.StatusInternalServerError) + })) + defer shutdown() + + client := Client{&httpClient} + + // act + actual, err := client.ArtistGetInfo("apiKey1", "Artist 1") + + // assert + require.Error(err) + require.Zero(actual) +} + +func TestGetParamSignature(t *testing.T) { + 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) + } +} diff --git a/scrobble/lastfm/lastfm.go b/scrobble/lastfm/lastfm.go deleted file mode 100644 index 9b1fe03..0000000 --- a/scrobble/lastfm/lastfm.go +++ /dev/null @@ -1,313 +0,0 @@ -package lastfm - -import ( - "crypto/md5" - "encoding/hex" - "encoding/xml" - "errors" - "fmt" - "log" - "net/http" - "net/http/httputil" - "net/url" - "sort" - "strconv" - "time" - - "github.com/andybalholm/cascadia" - "github.com/google/uuid" - "go.senan.xyz/gonic/db" - "go.senan.xyz/gonic/scrobble" - "golang.org/x/net/html" -) - -const ( - baseURL = "https://ws.audioscrobbler.com/2.0/" -) - -var ( - ErrLastFM = errors.New("last.fm error") -) - -type LastFM struct { - XMLName xml.Name `xml:"lfm"` - Status string `xml:"status,attr"` - Session Session `xml:"session"` - Error Error `xml:"error"` - Artist Artist `xml:"artist"` - TopTracks TopTracks `xml:"toptracks"` - SimilarTracks SimilarTracks `xml:"similartracks"` - SimilarArtists SimilarArtists `xml:"similarartists"` -} - -type Session struct { - Name string `xml:"name"` - Key string `xml:"key"` - Subscriber uint `xml:"subscriber"` -} - -type Error struct { - Code uint `xml:"code,attr"` - Value string `xml:",chardata"` -} - -type SimilarArtist struct { - XMLName xml.Name `xml:"artist"` - Name string `xml:"name"` - MBID string `xml:"mbid"` - URL string `xml:"url"` - Image []struct { - Text string `xml:",chardata"` - Size string `xml:"size,attr"` - } `xml:"image"` - Streamable string `xml:"streamable"` -} - -type ArtistImage struct { - Text string `xml:",chardata"` - Size string `xml:"size,attr"` -} - -type Artist struct { - XMLName xml.Name `xml:"artist"` - Name string `xml:"name"` - MBID string `xml:"mbid"` - URL string `xml:"url"` - Image []ArtistImage `xml:"image"` - Streamable string `xml:"streamable"` - Stats struct { - Listeners string `xml:"listeners"` - Plays string `xml:"plays"` - } `xml:"stats"` - Similar struct { - Artists []Artist `xml:"artist"` - } `xml:"similar"` - Tags struct { - Tag []ArtistTag `xml:"tag"` - } `xml:"tags"` - Bio ArtistBio `xml:"bio"` -} - -type ArtistTag struct { - Name string `xml:"name"` - URL string `xml:"url"` -} - -type ArtistBio struct { - Published string `xml:"published"` - Summary string `xml:"summary"` - Content string `xml:"content"` -} - -type TopTracks struct { - XMLName xml.Name `xml:"toptracks"` - Artist string `xml:"artist,attr"` - Tracks []Track `xml:"track"` -} - -type SimilarTracks struct { - XMLName xml.Name `xml:"similartracks"` - Artist string `xml:"artist,attr"` - Track string `xml:"track,attr"` - Tracks []Track `xml:"track"` -} - -type SimilarArtists struct { - XMLName xml.Name `xml:"similarartists"` - Artist string `xml:"artist,attr"` - Artists []Artist `xml:"artist"` -} - -type Track struct { - Rank int `xml:"rank,attr"` - Tracks []Track `xml:"track"` - Name string `xml:"name"` - MBID string `xml:"mbid"` - PlayCount int `xml:"playcount"` - Listeners int `xml:"listeners"` - URL string `xml:"url"` - Image []struct { - Text string `xml:",chardata"` - Size string `xml:"size,attr"` - } `xml:"image"` -} - -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 makeRequest(method string, params url.Values) (LastFM, error) { - req, _ := http.NewRequest(method, baseURL, nil) - req.URL.RawQuery = params.Encode() - resp, err := http.DefaultClient.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 { - respBytes, _ := httputil.DumpResponse(resp, true) - log.Printf("received bad lastfm response:\n%s", string(respBytes)) - return LastFM{}, fmt.Errorf("decoding: %w", err) - } - if lastfm.Error.Code != 0 { - respBytes, _ := httputil.DumpResponse(resp, true) - log.Printf("received bad lastfm response:\n%s", string(respBytes)) - return LastFM{}, fmt.Errorf("%v: %w", lastfm.Error.Value, ErrLastFM) - } - return lastfm, nil -} - -func 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 := makeRequest("GET", params) - if err != nil { - return Artist{}, fmt.Errorf("making artist GET: %w", err) - } - return resp.Artist, nil -} - -func 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 := makeRequest("GET", params) - if err != nil { - return TopTracks{}, fmt.Errorf("making track GET: %w", err) - } - return resp.TopTracks, nil -} - -func 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 := makeRequest("GET", params) - if err != nil { - return SimilarTracks{}, fmt.Errorf("making track GET: %w", err) - } - return resp.SimilarTracks, nil -} - -func 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 := makeRequest("GET", params) - if err != nil { - return SimilarArtists{}, fmt.Errorf("making similar artists GET: %w", err) - } - return resp.SimilarArtists, nil -} - -func 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 := makeRequest("GET", params) - if err != nil { - return "", fmt.Errorf("making session GET: %w", err) - } - return resp.Session.Key, nil -} - -type Scrobbler struct { - DB *db.DB -} - -func (s *Scrobbler) Scrobble(user *db.User, track *db.Track, stamp time.Time, submission bool) error { - if user.LastFMSession == "" { - return nil - } - apiKey, err := s.DB.GetSetting("lastfm_api_key") - if err != nil { - return fmt.Errorf("get api key: %w", err) - } - secret, err := s.DB.GetSetting("lastfm_secret") - 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", track.Artist.Name) - 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 = makeRequest("POST", params) - return err -} - -var _ scrobble.Scrobbler = (*Scrobbler)(nil) - -//nolint:gochecknoglobals -var artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`) - -func 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 -} diff --git a/scrobble/lastfm/lastfm_test.go b/scrobble/lastfm/lastfm_test.go deleted file mode 100644 index 4713a6d..0000000 --- a/scrobble/lastfm/lastfm_test.go +++ /dev/null @@ -1,23 +0,0 @@ -package lastfm - -import ( - "crypto/md5" - "fmt" - "net/url" - "testing" -) - -func TestGetParamSignature(t *testing.T) { - 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) - } -} diff --git a/scrobble/lastfm/model.go b/scrobble/lastfm/model.go new file mode 100644 index 0000000..a2731be --- /dev/null +++ b/scrobble/lastfm/model.go @@ -0,0 +1,108 @@ +package lastfm + +import "encoding/xml" + +type ( + LastFM struct { + XMLName xml.Name `xml:"lfm"` + Status string `xml:"status,attr"` + Session Session `xml:"session"` + Error Error `xml:"error"` + Artist Artist `xml:"artist"` + TopTracks TopTracks `xml:"toptracks"` + SimilarTracks SimilarTracks `xml:"similartracks"` + SimilarArtists SimilarArtists `xml:"similarartists"` + } + + Session struct { + Name string `xml:"name"` + Key string `xml:"key"` + Subscriber uint `xml:"subscriber"` + } + + Error struct { + Code uint `xml:"code,attr"` + Value string `xml:",chardata"` + } + + SimilarArtist struct { + XMLName xml.Name `xml:"artist"` + Name string `xml:"name"` + MBID string `xml:"mbid"` + URL string `xml:"url"` + Image []struct { + Text string `xml:",chardata"` + Size string `xml:"size,attr"` + } `xml:"image"` + Streamable string `xml:"streamable"` + } + + ArtistImage struct { + Text string `xml:",chardata"` + Size string `xml:"size,attr"` + } + + Artist struct { + XMLName xml.Name `xml:"artist"` + Name string `xml:"name"` + MBID string `xml:"mbid"` + URL string `xml:"url"` + Image []ArtistImage `xml:"image"` + Streamable string `xml:"streamable"` + Stats struct { + Listeners string `xml:"listeners"` + Playcount string `xml:"playcount"` + } `xml:"stats"` + Similar struct { + Artists []Artist `xml:"artist"` + } `xml:"similar"` + Tags struct { + Tag []ArtistTag `xml:"tag"` + } `xml:"tags"` + Bio ArtistBio `xml:"bio"` + } + + ArtistTag struct { + Name string `xml:"name"` + URL string `xml:"url"` + } + + ArtistBio struct { + Published string `xml:"published"` + Summary string `xml:"summary"` + Content string `xml:"content"` + } + + TopTracks struct { + XMLName xml.Name `xml:"toptracks"` + Artist string `xml:"artist,attr"` + Tracks []Track `xml:"track"` + } + + SimilarTracks struct { + XMLName xml.Name `xml:"similartracks"` + Artist string `xml:"artist,attr"` + Track string `xml:"track,attr"` + Tracks []Track `xml:"track"` + } + + SimilarArtists struct { + XMLName xml.Name `xml:"similarartists"` + Artist string `xml:"artist,attr"` + Artists []Artist `xml:"artist"` + } + + Track struct { + Rank int `xml:"rank,attr"` + Tracks []Track `xml:"track"` + Name string `xml:"name"` + MBID string `xml:"mbid"` + PlayCount int `xml:"playcount"` + Listeners int `xml:"listeners"` + URL string `xml:"url"` + Image []struct { + Text string `xml:",chardata"` + Size string `xml:"size,attr"` + } `xml:"image"` + } +) diff --git a/scrobble/lastfm/scrobbler.go b/scrobble/lastfm/scrobbler.go new file mode 100644 index 0000000..80e87ef --- /dev/null +++ b/scrobble/lastfm/scrobbler.go @@ -0,0 +1,67 @@ +package lastfm + +import ( + "fmt" + "net/url" + "strconv" + "time" + + "github.com/google/uuid" + "go.senan.xyz/gonic/db" + "go.senan.xyz/gonic/scrobble" +) + +type Scrobbler struct { + db *db.DB + client *Client +} + +var _ scrobble.Scrobbler = (*Scrobbler)(nil) + +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 + } + apiKey, err := s.db.GetSetting("lastfm_api_key") + if err != nil { + return fmt.Errorf("get api key: %w", err) + } + secret, err := s.db.GetSetting("lastfm_secret") + 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", track.Artist.Name) + 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("POST", params) + return err +} diff --git a/scrobble/lastfm/testdata/artist_get_info_response.xml b/scrobble/lastfm/testdata/artist_get_info_response.xml new file mode 100644 index 0000000..d059e10 --- /dev/null +++ b/scrobble/lastfm/testdata/artist_get_info_response.xml @@ -0,0 +1,36 @@ + + + + Artist 1 + 366c1119-ec4f-4312-b729-a5637d148e3e + https://www.last.fm/music/Artist+1 + https://last.fm/artist-1-small.png + 0 + 0 + + 1 + 2 + + + + Similar Artist 1 + https://www.last.fm/music/Similar+Artist+1 + https://last.fm/similar-artist-1-small.png + + + + + tag1 + https://www.last.fm/tag/tag1 + + + + + + + 13 May 2023, 00:24 + Summary + Content + + + \ No newline at end of file diff --git a/server/ctrladmin/ctrl.go b/server/ctrladmin/ctrl.go index afb5b77..a374349 100644 --- a/server/ctrladmin/ctrl.go +++ b/server/ctrladmin/ctrl.go @@ -24,6 +24,7 @@ import ( "go.senan.xyz/gonic" "go.senan.xyz/gonic/db" "go.senan.xyz/gonic/podcasts" + "go.senan.xyz/gonic/scrobble/lastfm" "go.senan.xyz/gonic/server/ctrladmin/adminui" "go.senan.xyz/gonic/server/ctrlbase" ) @@ -74,13 +75,14 @@ func funcMap() template.FuncMap { type Controller struct { *ctrlbase.Controller - buffPool *bpool.BufferPool - template *template.Template - sessDB *gormstore.Store - Podcasts *podcasts.Podcasts + buffPool *bpool.BufferPool + template *template.Template + sessDB *gormstore.Store + Podcasts *podcasts.Podcasts + lastfmClient *lastfm.Client } -func New(b *ctrlbase.Controller, sessDB *gormstore.Store, podcasts *podcasts.Podcasts) (*Controller, error) { +func New(b *ctrlbase.Controller, sessDB *gormstore.Store, podcasts *podcasts.Podcasts, lastfmClient *lastfm.Client) (*Controller, error) { tmpl, err := template. New("layout"). Funcs(template.FuncMap(sprig.FuncMap())). @@ -93,11 +95,12 @@ func New(b *ctrlbase.Controller, sessDB *gormstore.Store, podcasts *podcasts.Pod return nil, fmt.Errorf("build template: %w", err) } return &Controller{ - Controller: b, - buffPool: bpool.NewBufferPool(64), - template: tmpl, - sessDB: sessDB, - Podcasts: podcasts, + Controller: b, + buffPool: bpool.NewBufferPool(64), + template: tmpl, + sessDB: sessDB, + Podcasts: podcasts, + lastfmClient: lastfmClient, }, nil } diff --git a/server/ctrladmin/handlers.go b/server/ctrladmin/handlers.go index 2f4a202..3b08bee 100644 --- a/server/ctrladmin/handlers.go +++ b/server/ctrladmin/handlers.go @@ -20,7 +20,6 @@ import ( "go.senan.xyz/gonic/db" "go.senan.xyz/gonic/scanner" - "go.senan.xyz/gonic/scrobble/lastfm" "go.senan.xyz/gonic/scrobble/listenbrainz" "go.senan.xyz/gonic/transcode" ) @@ -106,7 +105,7 @@ func (c *Controller) ServeLinkLastFMDo(r *http.Request) *Response { if err != nil { return &Response{redirect: r.Referer(), flashW: []string{fmt.Sprintf("couldn't get secret: %v", err)}} } - sessionKey, err := lastfm.GetSession(apiKey, secret, token) + sessionKey, err := c.lastfmClient.GetSession(apiKey, secret, token) if err != nil { return &Response{ redirect: "/admin/home", diff --git a/server/ctrlsubsonic/ctrl.go b/server/ctrlsubsonic/ctrl.go index fea6e77..f06af5e 100644 --- a/server/ctrlsubsonic/ctrl.go +++ b/server/ctrlsubsonic/ctrl.go @@ -12,6 +12,7 @@ import ( "go.senan.xyz/gonic/jukebox" "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/ctrlsubsonic/params" "go.senan.xyz/gonic/server/ctrlsubsonic/spec" @@ -48,6 +49,7 @@ type Controller struct { Scrobblers []scrobble.Scrobbler Podcasts *podcasts.Podcasts Transcoder transcode.Transcoder + LastFMClient *lastfm.Client } type metaResponse struct { diff --git a/server/ctrlsubsonic/handlers_by_tags.go b/server/ctrlsubsonic/handlers_by_tags.go index 1f90ce8..b16ca50 100644 --- a/server/ctrlsubsonic/handlers_by_tags.go +++ b/server/ctrlsubsonic/handlers_by_tags.go @@ -13,7 +13,6 @@ import ( "github.com/jinzhu/gorm" "go.senan.xyz/gonic/db" - "go.senan.xyz/gonic/scrobble/lastfm" "go.senan.xyz/gonic/server/ctrlsubsonic/params" "go.senan.xyz/gonic/server/ctrlsubsonic/spec" "go.senan.xyz/gonic/server/ctrlsubsonic/specid" @@ -318,7 +317,7 @@ func (c *Controller) ServeGetArtistInfoTwo(r *http.Request) *spec.Response { if apiKey == "" { return sub } - info, err := lastfm.ArtistGetInfo(apiKey, artist.Name) + info, err := c.LastFMClient.ArtistGetInfo(apiKey, artist.Name) if err != nil { return spec.NewError(0, "fetching artist info: %v", err) } @@ -338,7 +337,7 @@ func (c *Controller) ServeGetArtistInfoTwo(r *http.Request) *spec.Response { sub.ArtistInfoTwo.LargeImageURL = image.Text } } - if url, _ := lastfm.StealArtistImage(info.URL); url != "" { + if url, _ := c.LastFMClient.StealArtistImage(info.URL); url != "" { sub.ArtistInfoTwo.SmallImageURL = url sub.ArtistInfoTwo.MediumImageURL = url sub.ArtistInfoTwo.LargeImageURL = url @@ -348,7 +347,7 @@ func (c *Controller) ServeGetArtistInfoTwo(r *http.Request) *spec.Response { count := params.GetOrInt("count", 20) inclNotPresent := params.GetOrBool("includeNotPresent", false) - similarArtists, err := lastfm.ArtistGetSimilar(apiKey, artist.Name) + similarArtists, err := c.LastFMClient.ArtistGetSimilar(apiKey, artist.Name) if err != nil { return spec.NewError(0, "fetching artist similar: %v", err) } @@ -542,7 +541,7 @@ func (c *Controller) ServeGetTopSongs(r *http.Request) *spec.Response { if apiKey == "" { return spec.NewResponse() } - topTracks, err := lastfm.ArtistGetTopTracks(apiKey, artist.Name) + topTracks, err := c.LastFMClient.ArtistGetTopTracks(apiKey, artist.Name) if err != nil { return spec.NewError(0, "fetching artist top tracks: %v", err) } @@ -610,7 +609,7 @@ func (c *Controller) ServeGetSimilarSongs(r *http.Request) *spec.Response { return spec.NewError(10, "couldn't find a track with that id") } - similarTracks, err := lastfm.TrackGetSimilarTracks(apiKey, track.Artist.Name, track.TagTitle) + similarTracks, err := c.LastFMClient.TrackGetSimilarTracks(apiKey, track.Artist.Name, track.TagTitle) if err != nil { return spec.NewError(0, "fetching track similar tracks: %v", err) } @@ -680,7 +679,7 @@ func (c *Controller) ServeGetSimilarSongsTwo(r *http.Request) *spec.Response { return spec.NewError(0, "artist with id `%s` not found", id) } - similarArtists, err := lastfm.ArtistGetSimilar(apiKey, artist.Name) + similarArtists, err := c.LastFMClient.ArtistGetSimilar(apiKey, artist.Name) if err != nil { return spec.NewError(0, "fetching artist similar artists: %v", err) } diff --git a/server/server.go b/server/server.go index f71ca36..05d7f24 100644 --- a/server/server.go +++ b/server/server.go @@ -97,19 +97,25 @@ func New(opts Options) (*Server, error) { opts.CacheAudioPath, ) - ctrlAdmin, err := ctrladmin.New(base, sessDB, podcast) + lastfmClient := lastfm.NewClient() + + ctrlAdmin, err := ctrladmin.New(base, sessDB, podcast, lastfmClient) if err != nil { return nil, fmt.Errorf("create admin controller: %w", err) } + ctrlSubsonic := &ctrlsubsonic.Controller{ Controller: base, MusicPaths: opts.MusicPaths, PodcastsPath: opts.PodcastPath, CacheAudioPath: opts.CacheAudioPath, CoverCachePath: opts.CoverCachePath, - Scrobblers: []scrobble.Scrobbler{&lastfm.Scrobbler{DB: opts.DB}, listenbrainz.NewScrobbler()}, - Podcasts: podcast, - Transcoder: cacheTranscoder, + Scrobblers: []scrobble.Scrobbler{ + lastfm.NewScrobbler(opts.DB, lastfmClient), + listenbrainz.NewScrobbler(), + }, + Podcasts: podcast, + Transcoder: cacheTranscoder, } setupMisc(r, base)