feat(listenbrainz): add SimilarRecordings client method

This commit is contained in:
2026-04-28 19:59:38 -04:00
parent d4e0e5fae3
commit 23d55a868b
2 changed files with 148 additions and 0 deletions
+53
View File
@@ -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)
}
}
@@ -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)
}
}