From 23d55a868bc78686cae9534af6b95eb381f462a8 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 28 Apr 2026 19:59:38 -0400 Subject: [PATCH] feat(listenbrainz): add SimilarRecordings client method --- internal/scrobble/listenbrainz/client.go | 53 +++++++++++ internal/scrobble/listenbrainz/client_test.go | 95 +++++++++++++++++++ 2 files changed, 148 insertions(+) diff --git a/internal/scrobble/listenbrainz/client.go b/internal/scrobble/listenbrainz/client.go index 949cae5b..e12dfce2 100644 --- a/internal/scrobble/listenbrainz/client.go +++ b/internal/scrobble/listenbrainz/client.go @@ -180,3 +180,56 @@ func buildPayload(listenType string, listens []Listen) payload { } return payload{ListenType: listenType, Payload: entries} } + +// LB algorithm parameter for /explore/similar-recordings/. Hardcoded for v1 +// — matches LB's documented default. +const lbSimilarRecordingsAlgorithm = "session_based_days_7500_session_30_contribution_5_threshold_15_limit_100_filter_True_skip_30" + +// SimilarRecording is one entry in the /explore/similar-recordings response. +type SimilarRecording struct { + MBID string `json:"recording_mbid"` + Score float64 `json:"score"` +} + +// SimilarRecordings fetches up to `limit` similar recordings for the given +// recording MBID. Public endpoint — no token required. Returns the same +// typed errors as SubmitListens. +func (c *Client) SimilarRecordings(ctx context.Context, mbid string, limit int) ([]SimilarRecording, error) { + base := c.BaseURL + if base == "" { + base = defaultBaseURL + } + url := fmt.Sprintf("%s/1/explore/similar-recordings/%s?algorithm=%s&count=%d", + base, mbid, lbSimilarRecordingsAlgorithm, limit) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("listenbrainz: build similar-recordings 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 []SimilarRecording + if derr := json.NewDecoder(resp.Body).Decode(&out); derr != nil { + return nil, fmt.Errorf("%w: decode similar-recordings: %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 70129bbe..3be7963f 100644 --- a/internal/scrobble/listenbrainz/client_test.go +++ b/internal/scrobble/listenbrainz/client_test.go @@ -162,3 +162,98 @@ func TestClient_SubmitListens_OmitsEmptyMBIDs(t *testing.T) { t.Errorf("body should omit empty recording_mbid: %s", seenBody) } } + +func TestClient_SimilarRecordings_Success(t *testing.T) { + c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`[ + {"recording_mbid": "11111111-1111-1111-1111-111111111111", "score": 0.95}, + {"recording_mbid": "22222222-2222-2222-2222-222222222222", "score": 0.80} + ]`)) + }) + defer srv.Close() + out, err := c.SimilarRecordings(context.Background(), "aaaa", 100) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(out) != 2 { + t.Fatalf("got %d results, want 2", len(out)) + } + if out[0].MBID != "11111111-1111-1111-1111-111111111111" || out[0].Score != 0.95 { + t.Errorf("first result wrong: %+v", out[0]) + } +} + +func TestClient_SimilarRecordings_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.SimilarRecordings(context.Background(), "abc", 50) + if !strings.Contains(seenURL, "algorithm=") { + t.Errorf("URL missing algorithm param: %q", seenURL) + } +} + +func TestClient_SimilarRecordings_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.SimilarRecordings(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_SimilarRecordings_401_ReturnsErrAuth(t *testing.T) { + c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }) + defer srv.Close() + _, err := c.SimilarRecordings(context.Background(), "abc", 100) + if !errors.Is(err, ErrAuth) { + t.Errorf("err = %v, want ErrAuth", err) + } +} + +func TestClient_SimilarRecordings_400_ReturnsErrPermanent(t *testing.T) { + c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + }) + defer srv.Close() + _, err := c.SimilarRecordings(context.Background(), "abc", 100) + if !errors.Is(err, ErrPermanent) { + t.Errorf("err = %v, want ErrPermanent", err) + } +} + +func TestClient_SimilarRecordings_503_ReturnsErrTransient(t *testing.T) { + c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + }) + defer srv.Close() + _, err := c.SimilarRecordings(context.Background(), "abc", 100) + if !errors.Is(err, ErrTransient) { + t.Errorf("err = %v, want ErrTransient", err) + } +} + +func TestClient_SimilarRecordings_429_ReturnsRetryAfter(t *testing.T) { + c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Retry-After", "120") + w.WriteHeader(http.StatusTooManyRequests) + }) + defer srv.Close() + _, err := c.SimilarRecordings(context.Background(), "abc", 100) + var ra *RetryAfterError + if !errors.As(err, &ra) { + t.Fatalf("err = %v, want *RetryAfterError", err) + } + if ra.Wait != 120*time.Second { + t.Errorf("Wait = %v, want 120s", ra.Wait) + } +}