diff --git a/adapters/listenbrainz/agent.go b/adapters/listenbrainz/agent.go index ff96376db..ef879654a 100644 --- a/adapters/listenbrainz/agent.go +++ b/adapters/listenbrainz/agent.go @@ -159,6 +159,31 @@ func (l *listenBrainzAgent) GetArtistTopSongs(ctx context.Context, id, artistNam return res, nil } +func (l *listenBrainzAgent) GetSimilarArtists(ctx context.Context, id string, name string, mbid string, limit int) ([]agents.Artist, error) { + if mbid == "" { + return nil, agents.ErrNotFound + } + + resp, err := l.client.getSimilarArtists(ctx, mbid) + if err != nil { + return nil, err + } + + if len(resp) == 0 { + return nil, agents.ErrNotFound + } + + artistCount := min(len(resp), limit) + artists := make([]agents.Artist, artistCount) + + for i := 0; i < artistCount; i++ { + artists[i].MBID = resp[i].MBID + artists[i].Name = resp[i].Name + } + + return artists, nil +} + func init() { conf.AddHook(func() { if conf.Server.ListenBrainz.Enabled { @@ -172,21 +197,21 @@ func init() { return nil }) - if conf.Server.ListenBrainz.EnableMetadata { - if conf.Server.ListenBrainz.BaseURL != "https://api.listenbrainz.org/1/" { - log.Warn("ListenBrainz is enabled with a nonstandard API endpoint. Requests will likely not work") + agents.Register(listenBrainzAgentName, func(ds model.DataStore) agents.Interface { + // This is a workaround for the fact that a (Interface)(nil) is not the same as a (*listenBrainzConstructor)(nil) + // See https://go.dev/doc/faq#nil_error + a := listenBrainzConstructor(ds) + if a != nil { + return a } - - agents.Register(listenBrainzAgentName, func(ds model.DataStore) agents.Interface { - // This is a workaround for the fact that a (Interface)(nil) is not the same as a (*listenBrainzConstructor)(nil) - // See https://go.dev/doc/faq#nil_error - a := listenBrainzConstructor(ds) - if a != nil { - return a - } - return nil - }) - } + return nil + }) } }) } + +var ( + _ agents.ArtistTopSongsRetriever = (*listenBrainzAgent)(nil) + _ agents.ArtistURLRetriever = (*listenBrainzAgent)(nil) + _ agents.ArtistSimilarRetriever = (*listenBrainzAgent)(nil) +) diff --git a/adapters/listenbrainz/agent_test.go b/adapters/listenbrainz/agent_test.go index f0a991c60..cc61e10e9 100644 --- a/adapters/listenbrainz/agent_test.go +++ b/adapters/listenbrainz/agent_test.go @@ -228,7 +228,7 @@ var _ = Describe("listenBrainzAgent", func() { _, err := agent.GetArtistTopSongs(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 1) Expect(err).To(HaveOccurred()) Expect(httpClient.RequestCount).To(Equal(1)) - Expect(httpClient.SavedRequest.URL.Path).To(Equal("BASE_URL/popularity/top-recordings-for-artist/d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56")) + Expect(httpClient.SavedRequest.URL.Path).To(Equal("/1/popularity/top-recordings-for-artist/d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56")) }) It("returns an error on listenbrainz error", func() { @@ -239,7 +239,7 @@ var _ = Describe("listenBrainzAgent", func() { _, err := agent.GetArtistTopSongs(ctx, "", "", "1", 1) Expect(err).To(HaveOccurred()) Expect(httpClient.RequestCount).To(Equal(1)) - Expect(httpClient.SavedRequest.URL.Path).To(Equal("BASE_URL/popularity/top-recordings-for-artist/1")) + Expect(httpClient.SavedRequest.URL.Path).To(Equal("/1/popularity/top-recordings-for-artist/1")) }) It("returns all tracks when asked", func() { @@ -290,4 +290,63 @@ var _ = Describe("listenBrainzAgent", func() { })) }) }) + + Describe("GetSimilarArtists", func() { + var agent *listenBrainzAgent + var httpClient *tests.FakeHttpClient + baseUrl := "https://labs.api.listenbrainz.org/similar-artists/json" + + BeforeEach(func() { + httpClient = &tests.FakeHttpClient{} + client := newClient("BASE_URL", httpClient) + agent = listenBrainzConstructor(ds) + agent.client = client + }) + + It("returns error when fetch calls", func() { + httpClient.Err = errors.New("error") + _, err := agent.GetSimilarArtists(ctx, "", "", "db92a151-1ac2-438b-bc43-b82e149ddd50", 1) + Expect(err).To(HaveOccurred()) + Expect(httpClient.RequestCount).To(Equal(1)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "?algorithm=session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30&artist_mbids=db92a151-1ac2-438b-bc43-b82e149ddd50")) + }) + + It("returns an error on listenbrainz error", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`Bad request`)), + StatusCode: 400, + } + _, err := agent.GetSimilarArtists(ctx, "", "", "1", 1) + Expect(err).To(HaveOccurred()) + Expect(httpClient.RequestCount).To(Equal(1)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "?algorithm=session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30&artist_mbids=1")) + }) + + It("returns all data on call", func() { + f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + + resp, err := agent.GetSimilarArtists(ctx, "", "", "db92a151-1ac2-438b-bc43-b82e149ddd50", 2) + Expect(err).ToNot(HaveOccurred()) + Expect(httpClient.RequestCount).To(Equal(1)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "?algorithm=session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30&artist_mbids=db92a151-1ac2-438b-bc43-b82e149ddd50")) + Expect(resp).To(Equal([]agents.Artist{ + {MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson"}, + {MBID: "7364dea6-ca9a-48e3-be01-b44ad0d19897", Name: "a-ha"}, + })) + }) + + It("returns subset of data on call", func() { + f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + + resp, err := agent.GetSimilarArtists(ctx, "", "", "db92a151-1ac2-438b-bc43-b82e149ddd50", 1) + Expect(err).ToNot(HaveOccurred()) + Expect(httpClient.RequestCount).To(Equal(1)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "?algorithm=session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30&artist_mbids=db92a151-1ac2-438b-bc43-b82e149ddd50")) + Expect(resp).To(Equal([]agents.Artist{ + {MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson"}, + })) + }) + }) }) diff --git a/adapters/listenbrainz/client.go b/adapters/listenbrainz/client.go index 84c17b972..e38c16907 100644 --- a/adapters/listenbrainz/client.go +++ b/adapters/listenbrainz/client.go @@ -13,6 +13,13 @@ import ( "github.com/navidrome/navidrome/log" ) +const ( + lbzApiUrl = "https://api.listenbrainz.org/1/" + labsBase = "https://labs.api.listenbrainz.org/" + // There are a couple of algorithms from https://labs.api.listenbrainz.org/similar-artists + algorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30" +) + var ( ErrorNotFound = errors.New("listenbrainz: not found") ) @@ -189,11 +196,7 @@ type lbzHttpError struct { } func (c *client) makeGenericRequest(ctx context.Context, method string, endpoint string, params url.Values) (*http.Response, error) { - uri, err := c.path(endpoint) - if err != nil { - return nil, err - } - req, _ := http.NewRequestWithContext(ctx, method, uri, nil) + req, _ := http.NewRequestWithContext(ctx, method, lbzApiUrl+endpoint, nil) req.Header.Add("Content-Type", "application/json; charset=UTF-8") req.URL.RawQuery = params.Encode() @@ -283,3 +286,34 @@ func (c *client) getArtistTopSongs(ctx context.Context, mbid string, count int) return response, nil } + +type artist struct { + MBID string `json:"artist_mbid"` + Name string `json:"name"` +} + +func (c *client) getSimilarArtists(ctx context.Context, mbid string) ([]artist, error) { + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, labsBase+"similar-artists/json", nil) + req.Header.Add("Content-Type", "application/json; charset=UTF-8") + req.URL.RawQuery = url.Values{ + "artist_mbids": []string{mbid}, "algorithm": []string{algorithm}, + }.Encode() + + log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz Labs %s request", req.Method), "url", req.URL) + resp, err := c.hc.Do(req) + + if err != nil { + return nil, err + } + + defer resp.Body.Close() + decoder := json.NewDecoder(resp.Body) + + var artists []artist + jsonErr := decoder.Decode(&artists) + if jsonErr != nil { + return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode) + } + + return artists, nil +} diff --git a/adapters/listenbrainz/client_test.go b/adapters/listenbrainz/client_test.go index 77b96016d..dc31e61a2 100644 --- a/adapters/listenbrainz/client_test.go +++ b/adapters/listenbrainz/client_test.go @@ -119,7 +119,7 @@ var _ = Describe("client", func() { }) Context("getArtistUrl", func() { - baseUrl := "BASE_URL/metadata/artist?" + baseUrl := "https://api.listenbrainz.org/1/metadata/artist?" It("handles a malformed request with status code", func() { httpClient.Res = http.Response{ Body: io.NopCloser(bytes.NewBufferString(`{"code": 400,"error": "artist mbid 1 is not valid."}`)), @@ -167,7 +167,7 @@ var _ = Describe("client", func() { }) Context("getArtistTopSongs", func() { - baseUrl := "BASE_URL/popularity/top-recordings-for-artist/" + baseUrl := "https://api.listenbrainz.org/1/popularity/top-recordings-for-artist/" It("handles a malformed request with status code", func() { httpClient.Res = http.Response{ @@ -244,4 +244,34 @@ var _ = Describe("client", func() { Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) }) }) + + Context("getSimilarArtists", func() { + baseUrl := "https://labs.api.listenbrainz.org/similar-artists/json" + + It("handles a malformed request with status code", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`Bad request`)), + StatusCode: 400, + } + _, err := client.getSimilarArtists(context.Background(), "1") + Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (400)")) + Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "?algorithm=session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30&artist_mbids=1")) + Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) + }) + + It("handles real data properly", func() { + f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + resp, err := client.getSimilarArtists(context.Background(), "db92a151-1ac2-438b-bc43-b82e149ddd50") + Expect(err).ToNot(HaveOccurred()) + Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "?algorithm=session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30&artist_mbids=db92a151-1ac2-438b-bc43-b82e149ddd50")) + Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) + Expect(resp).To(Equal([]artist{ + {MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson"}, + {MBID: "7364dea6-ca9a-48e3-be01-b44ad0d19897", Name: "a-ha"}, + })) + }) + }) }) diff --git a/conf/configuration.go b/conf/configuration.go index 485812a14..ad7bb2d0b 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -194,9 +194,8 @@ type deezerOptions struct { } type listenBrainzOptions struct { - Enabled bool - EnableMetadata bool - BaseURL string + Enabled bool + BaseURL string } type httpHeaderOptions struct { @@ -657,7 +656,6 @@ func setViperDefaults() { viper.SetDefault("deezer.enabled", true) viper.SetDefault("deezer.language", consts.DefaultInfoLanguage) viper.SetDefault("listenbrainz.enabled", true) - viper.SetDefault("listenbrainz.enablemetadata", true) viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/") viper.SetDefault("enablescrobblehistory", true) viper.SetDefault("httpheaders.frameoptions", "DENY")