diff --git a/cmd/gonic/gonic.go b/cmd/gonic/gonic.go index 05c4162..28fe174 100644 --- a/cmd/gonic/gonic.go +++ b/cmd/gonic/gonic.go @@ -26,13 +26,12 @@ import ( "go.senan.xyz/gonic" "go.senan.xyz/gonic/db" "go.senan.xyz/gonic/jukebox" + "go.senan.xyz/gonic/lastfm" + "go.senan.xyz/gonic/listenbrainz" "go.senan.xyz/gonic/playlist" "go.senan.xyz/gonic/podcasts" "go.senan.xyz/gonic/scanner" "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/ctrlbase" "go.senan.xyz/gonic/server/ctrlsubsonic" @@ -180,7 +179,19 @@ func main() { transcode.NewFFmpegTranscoder(), 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) if err != nil { log.Panicf("error creating playlists store: %v", err) @@ -224,9 +235,9 @@ func main() { CacheCoverPath: cacheDirCovers, LastFMClient: lastfmClient, ArtistInfoCache: artistInfoCache, - Scrobblers: []scrobble.Scrobbler{ - lastfm.NewScrobbler(dbc, lastfmClient), - listenbrainz.NewScrobbler(), + Scrobblers: []ctrlsubsonic.Scrobbler{ + lastfmClient, + listenbrainzClient, }, Podcasts: podcast, Transcoder: transcoder, @@ -349,11 +360,10 @@ func main() { }) } - lastfmAPIKey, _ := dbc.GetSetting(db.LastFMAPIKey) - if lastfmAPIKey != "" { + if _, _, err := lastfmClientKeySecretFunc(); err == nil { g.Add(func() error { log.Printf("starting job 'refresh artist info'\n") - return artistInfoCache.Refresh(lastfmAPIKey, 8*time.Second) + return artistInfoCache.Refresh(8 * time.Second) }, noCleanup) } diff --git a/lastfm/client.go b/lastfm/client.go new file mode 100644 index 0000000..e00e41d --- /dev/null +++ b/lastfm/client.go @@ -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[:]) +} diff --git a/lastfm/client_test.go b/lastfm/client_test.go new file mode 100644 index 0000000..caf5c47 --- /dev/null +++ b/lastfm/client_test.go @@ -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) + } +} diff --git a/scrobble/lastfm/mockclient/artist_get_info_response.xml b/lastfm/mockclient/artist_get_info_response.xml similarity index 100% rename from scrobble/lastfm/mockclient/artist_get_info_response.xml rename to lastfm/mockclient/artist_get_info_response.xml diff --git a/scrobble/lastfm/mockclient/artist_get_similar_response.xml b/lastfm/mockclient/artist_get_similar_response.xml similarity index 100% rename from scrobble/lastfm/mockclient/artist_get_similar_response.xml rename to lastfm/mockclient/artist_get_similar_response.xml diff --git a/scrobble/lastfm/mockclient/artist_get_top_tracks_response.xml b/lastfm/mockclient/artist_get_top_tracks_response.xml similarity index 100% rename from scrobble/lastfm/mockclient/artist_get_top_tracks_response.xml rename to lastfm/mockclient/artist_get_top_tracks_response.xml diff --git a/scrobble/lastfm/mockclient/get_session_response.xml b/lastfm/mockclient/get_session_response.xml similarity index 100% rename from scrobble/lastfm/mockclient/get_session_response.xml rename to lastfm/mockclient/get_session_response.xml diff --git a/scrobble/lastfm/mockclient/mockclient.go b/lastfm/mockclient/mockclient.go similarity index 100% rename from scrobble/lastfm/mockclient/mockclient.go rename to lastfm/mockclient/mockclient.go diff --git a/scrobble/lastfm/mockclient/track_get_similar_response.xml b/lastfm/mockclient/track_get_similar_response.xml similarity index 100% rename from scrobble/lastfm/mockclient/track_get_similar_response.xml rename to lastfm/mockclient/track_get_similar_response.xml diff --git a/scrobble/lastfm/model.go b/lastfm/model.go similarity index 100% rename from scrobble/lastfm/model.go rename to lastfm/model.go diff --git a/scrobble/listenbrainz/listenbrainz.go b/listenbrainz/listenbrainz.go similarity index 82% rename from scrobble/listenbrainz/listenbrainz.go rename to listenbrainz/listenbrainz.go index 872fbf1..cb7f4cb 100644 --- a/scrobble/listenbrainz/listenbrainz.go +++ b/listenbrainz/listenbrainz.go @@ -12,7 +12,6 @@ import ( "github.com/google/uuid" "go.senan.xyz/gonic/db" - "go.senan.xyz/gonic/scrobble" ) const ( @@ -25,21 +24,23 @@ const ( var ErrListenBrainz = errors.New("listenbrainz error") -type Scrobbler struct { +type Client struct { httpClient *http.Client } -func NewScrobbler() *Scrobbler { - return &Scrobbler{ - httpClient: http.DefaultClient, - } +func NewClient() *Client { + return NewClientCustom(http.DefaultClient) } -func (s *Scrobbler) Scrobble(user *db.User, track *db.Track, stamp time.Time, submission bool) error { - if user.ListenBrainzURL == "" || user.ListenBrainzToken == "" { - return nil - } +func NewClientCustom(httpClient *http.Client) *Client { + return &Client{httpClient: httpClient} +} +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 var trackMBID string 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{ Payload: []*Payload{payload}, } + if submission && len(scrobble.Payload) > 0 { scrobble.ListenType = listenTypeSingle 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 { return err } + submitURL := fmt.Sprintf("%s%s", user.ListenBrainzURL, submitPath) authHeader := fmt.Sprintf("Token %s", user.ListenBrainzToken) + req, _ := http.NewRequest(http.MethodPost, submitURL, &payloadBuf) req.Header.Add("Content-Type", "application/json") req.Header.Add("Authorization", authHeader) - resp, err := s.httpClient.Do(req) + + resp, err := c.httpClient.Do(req) if err != nil { 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 } - -var _ scrobble.Scrobbler = (*Scrobbler)(nil) diff --git a/listenbrainz/listenbrainz_test.go b/listenbrainz/listenbrainz_test.go new file mode 100644 index 0000000..4fae597 --- /dev/null +++ b/listenbrainz/listenbrainz_test.go @@ -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 diff --git a/scrobble/listenbrainz/model.go b/listenbrainz/model.go similarity index 100% rename from scrobble/listenbrainz/model.go rename to listenbrainz/model.go diff --git a/scrobble/listenbrainz/testdata/submit_listens_request.json b/listenbrainz/testdata/submit_listens_request.json similarity index 100% rename from scrobble/listenbrainz/testdata/submit_listens_request.json rename to listenbrainz/testdata/submit_listens_request.json diff --git a/scrobble/lastfm/client.go b/scrobble/lastfm/client.go deleted file mode 100644 index b1d5789..0000000 --- a/scrobble/lastfm/client.go +++ /dev/null @@ -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 -} diff --git a/scrobble/lastfm/client_test.go b/scrobble/lastfm/client_test.go deleted file mode 100644 index 37e2787..0000000 --- a/scrobble/lastfm/client_test.go +++ /dev/null @@ -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) - } -} diff --git a/scrobble/lastfm/scrobbler.go b/scrobble/lastfm/scrobbler.go deleted file mode 100644 index 461ea20..0000000 --- a/scrobble/lastfm/scrobbler.go +++ /dev/null @@ -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 -} diff --git a/scrobble/lastfm/scrobbler_test.go b/scrobble/lastfm/scrobbler_test.go deleted file mode 100644 index 395a5f9..0000000 --- a/scrobble/lastfm/scrobbler_test.go +++ /dev/null @@ -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) -} diff --git a/scrobble/listenbrainz/listenbrainz_test.go b/scrobble/listenbrainz/listenbrainz_test.go deleted file mode 100644 index 9eac802..0000000 --- a/scrobble/listenbrainz/listenbrainz_test.go +++ /dev/null @@ -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) -} diff --git a/scrobble/scrobble.go b/scrobble/scrobble.go deleted file mode 100644 index 45700c2..0000000 --- a/scrobble/scrobble.go +++ /dev/null @@ -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 -} diff --git a/server/ctrladmin/ctrl.go b/server/ctrladmin/ctrl.go index 31506c9..6a22e95 100644 --- a/server/ctrladmin/ctrl.go +++ b/server/ctrladmin/ctrl.go @@ -22,8 +22,8 @@ import ( "go.senan.xyz/gonic" "go.senan.xyz/gonic/db" + "go.senan.xyz/gonic/lastfm" "go.senan.xyz/gonic/podcasts" - "go.senan.xyz/gonic/scrobble/lastfm" "go.senan.xyz/gonic/server/ctrladmin/adminui" "go.senan.xyz/gonic/server/ctrlbase" ) diff --git a/server/ctrladmin/handlers.go b/server/ctrladmin/handlers.go index 35fe0e6..4944c50 100644 --- a/server/ctrladmin/handlers.go +++ b/server/ctrladmin/handlers.go @@ -19,8 +19,8 @@ import ( "github.com/nfnt/resize" "go.senan.xyz/gonic/db" + "go.senan.xyz/gonic/listenbrainz" "go.senan.xyz/gonic/scanner" - "go.senan.xyz/gonic/scrobble/listenbrainz" "go.senan.xyz/gonic/transcode" ) @@ -97,15 +97,7 @@ func (c *Controller) ServeLinkLastFMDo(r *http.Request) *Response { if token == "" { return &Response{code: 400, err: "please provide a token"} } - apiKey, err := c.DB.GetSetting(db.LastFMAPIKey) - 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) + sessionKey, err := c.lastfmClient.GetSession(token) if err != nil { return &Response{ redirect: "/admin/home", diff --git a/server/ctrlsubsonic/artistinfocache/artistinfocache.go b/server/ctrlsubsonic/artistinfocache/artistinfocache.go index a2630ea..8a7f600 100644 --- a/server/ctrlsubsonic/artistinfocache/artistinfocache.go +++ b/server/ctrlsubsonic/artistinfocache/artistinfocache.go @@ -10,7 +10,7 @@ import ( "github.com/jinzhu/gorm" "go.senan.xyz/gonic/db" - "go.senan.xyz/gonic/scrobble/lastfm" + "go.senan.xyz/gonic/lastfm" ) 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} } -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 if err := a.db.Find(&artist, "id=?", artistID).Error; err != nil { 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 { - return a.Lookup(ctx, apiKey, &artist) + return a.Lookup(ctx, &artist) } return &artistInfo, nil @@ -50,7 +50,7 @@ func (a *ArtistInfoCache) Get(ctx context.Context, artistID int) (*db.ArtistInfo 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 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) } - info, err := a.lastfmClient.ArtistGetInfo(apiKey, artist.Name) + info, err := a.lastfmClient.ArtistGetInfo(artist.Name) if err != nil { 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) artistInfo.ImageURL = url - topTracksResponse, err := a.lastfmClient.ArtistGetTopTracks(apiKey, artist.Name) + topTracksResponse, err := a.lastfmClient.ArtistGetTopTracks(artist.Name) if err != nil { 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 } -func (a *ArtistInfoCache) Refresh(apiKey string, interval time.Duration) error { +func (a *ArtistInfoCache) Refresh(interval time.Duration) error { ticker := time.NewTicker(interval) for range ticker.C { q := a.db. @@ -110,7 +110,7 @@ func (a *ArtistInfoCache) Refresh(apiKey string, interval time.Duration) error { 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) continue } diff --git a/server/ctrlsubsonic/artistinfocache/artistinfocache_test.go b/server/ctrlsubsonic/artistinfocache/artistinfocache_test.go index 4c272ae..3e4edeb 100644 --- a/server/ctrlsubsonic/artistinfocache/artistinfocache_test.go +++ b/server/ctrlsubsonic/artistinfocache/artistinfocache_test.go @@ -9,9 +9,9 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "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/scrobble/lastfm" - "go.senan.xyz/gonic/scrobble/lastfm/mockclient" ) func TestInfoCache(t *testing.T) { @@ -29,20 +29,25 @@ func TestInfoCache(t *testing.T) { assert.Nil(artist.Info) var count atomic.Int32 - lastfmClient := lastfm.NewClientCustom(mockclient.New(t, func(w http.ResponseWriter, r *http.Request) { - switch method := r.URL.Query().Get("method"); method { - case "artist.getInfo": - count.Add(1) - w.Write(mockclient.ArtistGetInfoResponse) - case "artist.getTopTracks": - w.Write(mockclient.ArtistGetTopTracksResponse) - } - })) + lastfmClient := lastfm.NewClientCustom( + mockclient.New(t, func(w http.ResponseWriter, r *http.Request) { + switch method := r.URL.Query().Get("method"); method { + case "artist.getInfo": + count.Add(1) + w.Write(mockclient.ArtistGetInfoResponse) + case "artist.getTopTracks": + w.Write(mockclient.ArtistGetTopTracksResponse) + } + }), + func() (apiKey string, secret string, err error) { + return "", "", nil + }, + ) cache := New(m.DB(), lastfmClient) - _, err := cache.GetOrLookup(context.Background(), "", artist.ID) + _, err := cache.GetOrLookup(context.Background(), artist.ID) require.NoError(t, err) - _, err = cache.GetOrLookup(context.Background(), "", artist.ID) + _, err = cache.GetOrLookup(context.Background(), artist.ID) require.NoError(t, err) require.Equal(t, int32(1), count.Load()) diff --git a/server/ctrlsubsonic/ctrl.go b/server/ctrlsubsonic/ctrl.go index 889eb56..9b4457a 100644 --- a/server/ctrlsubsonic/ctrl.go +++ b/server/ctrlsubsonic/ctrl.go @@ -7,11 +7,12 @@ import ( "io" "log" "net/http" + "time" + "go.senan.xyz/gonic/db" "go.senan.xyz/gonic/jukebox" + "go.senan.xyz/gonic/lastfm" "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/artistinfocache" "go.senan.xyz/gonic/server/ctrlsubsonic/params" @@ -39,6 +40,11 @@ func PathsOf(paths []MusicPath) []string { 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 { *ctrlbase.Controller MusicPaths []MusicPath @@ -46,7 +52,7 @@ type Controller struct { CacheAudioPath string CacheCoverPath string Jukebox *jukebox.Jukebox - Scrobblers []scrobble.Scrobbler + Scrobblers []Scrobbler Podcasts *podcasts.Podcasts Transcoder transcode.Transcoder LastFMClient *lastfm.Client diff --git a/server/ctrlsubsonic/handlers_by_tags.go b/server/ctrlsubsonic/handlers_by_tags.go index 60166a2..2fd4fc5 100644 --- a/server/ctrlsubsonic/handlers_by_tags.go +++ b/server/ctrlsubsonic/handlers_by_tags.go @@ -3,6 +3,7 @@ package ctrlsubsonic import ( "errors" "fmt" + "log" "math" "net/http" "net/url" @@ -318,14 +319,10 @@ func (c *Controller) ServeGetArtistInfoTwo(r *http.Request) *spec.Response { sub := spec.NewResponse() sub.ArtistInfoTwo = &spec.ArtistInfo{} - apiKey, _ := c.DB.GetSetting(db.LastFMAPIKey) - if apiKey == "" { - return sub - } - - info, err := c.ArtistInfoCache.GetOrLookup(r.Context(), apiKey, artist.ID) + info, err := c.ArtistInfoCache.GetOrLookup(r.Context(), artist.ID) 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 @@ -541,13 +538,10 @@ func (c *Controller) ServeGetTopSongs(r *http.Request) *spec.Response { return spec.NewError(0, "finding artist by name: %v", err) } - apiKey, _ := c.DB.GetSetting(db.LastFMAPIKey) - if apiKey == "" { - return spec.NewResponse() - } - info, err := c.ArtistInfoCache.GetOrLookup(r.Context(), apiKey, artist.ID) + info, err := c.ArtistInfoCache.GetOrLookup(r.Context(), artist.ID) 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() @@ -597,10 +591,6 @@ func (c *Controller) ServeGetSimilarSongs(r *http.Request) *spec.Response { if err != nil || id.Type != specid.Track { 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 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") } - similarTracks, err := c.LastFMClient.TrackGetSimilarTracks(apiKey, track.TagTrackArtist, track.TagTitle) + similarTracks, err := c.LastFMClient.TrackGetSimilarTracks(track.TagTrackArtist, track.TagTitle) 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 { 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") } - apiKey, _ := c.DB.GetSetting(db.LastFMAPIKey) - if apiKey == "" { - return spec.NewResponse() - } - var artist db.Artist err = c.DB. 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) } - similarArtists, err := c.LastFMClient.ArtistGetSimilar(apiKey, artist.Name) + similarArtists, err := c.LastFMClient.ArtistGetSimilar(artist.Name) 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 { return spec.NewError(0, "no similar artist found for: %v", artist.Name) diff --git a/server/ctrlsubsonic/handlers_common.go b/server/ctrlsubsonic/handlers_common.go index 53d9de4..a398806 100644 --- a/server/ctrlsubsonic/handlers_common.go +++ b/server/ctrlsubsonic/handlers_common.go @@ -74,6 +74,9 @@ func (c *Controller) ServeScrobble(r *http.Request) *spec.Response { var scrobbleErrs []error for _, scrobbler := range c.Scrobblers { + if !scrobbler.IsUserAuthenticated(user) { + continue + } if err := scrobbler.Scrobble(user, track, optStamp, optSubmission); err != nil { scrobbleErrs = append(scrobbleErrs, err) }