diff --git a/internal/scrobble/listenbrainz/client.go b/internal/scrobble/listenbrainz/client.go index e12dfce2..dafac9ee 100644 --- a/internal/scrobble/listenbrainz/client.go +++ b/internal/scrobble/listenbrainz/client.go @@ -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) + } +} diff --git a/internal/scrobble/listenbrainz/client_test.go b/internal/scrobble/listenbrainz/client_test.go index 3be7963f..e04a83f2 100644 --- a/internal/scrobble/listenbrainz/client_test.go +++ b/internal/scrobble/listenbrainz/client_test.go @@ -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) + } +}