feat(listenbrainz): add SimilarArtists client method

This commit is contained in:
2026-04-28 20:01:17 -04:00
parent 23d55a868b
commit b0d1d6bb46
2 changed files with 148 additions and 0 deletions
+53
View File
@@ -233,3 +233,56 @@ func (c *Client) SimilarRecordings(ctx context.Context, mbid string, limit int)
return nil, fmt.Errorf("%w: status %d", ErrPermanent, resp.StatusCode)
}
}
// LB algorithm parameter for /explore/similar-artists/. Hardcoded for v1
// — matches LB's documented default.
const lbSimilarArtistsAlgorithm = "session_based_days_7500_session_30_contribution_5_threshold_15_limit_100_filter_True_skip_30"
// SimilarArtist is one entry in the /explore/similar-artists response.
type SimilarArtist struct {
MBID string `json:"artist_mbid"`
Score float64 `json:"score"`
}
// SimilarArtists fetches up to `limit` similar artists for the given
// artist MBID. Public endpoint — no token required. Returns the same
// typed errors as SubmitListens.
func (c *Client) SimilarArtists(ctx context.Context, mbid string, limit int) ([]SimilarArtist, error) {
base := c.BaseURL
if base == "" {
base = defaultBaseURL
}
url := fmt.Sprintf("%s/1/explore/similar-artists/%s?algorithm=%s&count=%d",
base, mbid, lbSimilarArtistsAlgorithm, limit)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("listenbrainz: build similar-artists request: %w", err)
}
httpClient := c.HTTP
if httpClient == nil {
httpClient = http.DefaultClient
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrTransient, err)
}
defer func() { _ = resp.Body.Close() }()
switch {
case resp.StatusCode >= 200 && resp.StatusCode < 300:
var out []SimilarArtist
if derr := json.NewDecoder(resp.Body).Decode(&out); derr != nil {
return nil, fmt.Errorf("%w: decode similar-artists: %v", ErrTransient, derr)
}
return out, nil
case resp.StatusCode == http.StatusUnauthorized:
return nil, ErrAuth
case resp.StatusCode == http.StatusTooManyRequests:
return nil, &RetryAfterError{Wait: parseRetryAfter(resp.Header.Get("Retry-After"))}
case resp.StatusCode >= 500:
return nil, fmt.Errorf("%w: status %d", ErrTransient, resp.StatusCode)
default:
return nil, fmt.Errorf("%w: status %d", ErrPermanent, resp.StatusCode)
}
}
@@ -257,3 +257,98 @@ func TestClient_SimilarRecordings_429_ReturnsRetryAfter(t *testing.T) {
t.Errorf("Wait = %v, want 120s", ra.Wait)
}
}
func TestClient_SimilarArtists_Success(t *testing.T) {
c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`[
{"artist_mbid": "33333333-3333-3333-3333-333333333333", "score": 0.91},
{"artist_mbid": "44444444-4444-4444-4444-444444444444", "score": 0.72}
]`))
})
defer srv.Close()
out, err := c.SimilarArtists(context.Background(), "aaaa", 100)
if err != nil {
t.Fatalf("err: %v", err)
}
if len(out) != 2 {
t.Fatalf("got %d, want 2", len(out))
}
if out[0].MBID != "33333333-3333-3333-3333-333333333333" || out[0].Score != 0.91 {
t.Errorf("first result wrong: %+v", out[0])
}
}
func TestClient_SimilarArtists_AlgorithmParamSet(t *testing.T) {
var seenURL string
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
seenURL = r.URL.String()
_, _ = w.Write([]byte(`[]`))
})
defer srv.Close()
_, _ = c.SimilarArtists(context.Background(), "abc", 50)
if !strings.Contains(seenURL, "algorithm=") {
t.Errorf("URL missing algorithm param: %q", seenURL)
}
}
func TestClient_SimilarArtists_LimitParamSet(t *testing.T) {
var seenURL string
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
seenURL = r.URL.String()
_, _ = w.Write([]byte(`[]`))
})
defer srv.Close()
_, _ = c.SimilarArtists(context.Background(), "abc", 50)
if !strings.Contains(seenURL, "count=50") && !strings.Contains(seenURL, "limit=50") {
t.Errorf("URL missing count/limit param: %q", seenURL)
}
}
func TestClient_SimilarArtists_401_ReturnsErrAuth(t *testing.T) {
c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
})
defer srv.Close()
_, err := c.SimilarArtists(context.Background(), "abc", 100)
if !errors.Is(err, ErrAuth) {
t.Errorf("err = %v, want ErrAuth", err)
}
}
func TestClient_SimilarArtists_400_ReturnsErrPermanent(t *testing.T) {
c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
})
defer srv.Close()
_, err := c.SimilarArtists(context.Background(), "abc", 100)
if !errors.Is(err, ErrPermanent) {
t.Errorf("err = %v, want ErrPermanent", err)
}
}
func TestClient_SimilarArtists_503_ReturnsErrTransient(t *testing.T) {
c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
})
defer srv.Close()
_, err := c.SimilarArtists(context.Background(), "abc", 100)
if !errors.Is(err, ErrTransient) {
t.Errorf("err = %v, want ErrTransient", err)
}
}
func TestClient_SimilarArtists_429_ReturnsRetryAfter(t *testing.T) {
c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Retry-After", "60")
w.WriteHeader(http.StatusTooManyRequests)
})
defer srv.Close()
_, err := c.SimilarArtists(context.Background(), "abc", 100)
var ra *RetryAfterError
if !errors.As(err, &ra) {
t.Fatalf("err = %v, want *RetryAfterError", err)
}
if ra.Wait != 60*time.Second {
t.Errorf("Wait = %v, want 60s", ra.Wait)
}
}