fix(listenbrainz): similarity hits Labs API, not the 404'ing main API

Root cause of zero LB recommendations (every similar-recordings AND
similar-artists call returned HTTP 404, worker logs):

- Wrong host/path: client called
  api.listenbrainz.org/1/explore/similar-{recordings,artists}/{mbid}.
  /explore/... is a WEBSITE route, not an API endpoint — it 308s then
  404s. Similarity datasets live on the separate Labs API.
- Invalid algorithm: the hardcoded
  session_…_session_30_…_limit_100_filter_True_… is not a permitted
  Labs enum member (400s) regardless of host.

Verified against the live Labs API:
  GET labs.api.listenbrainz.org/similar-recordings/json
      ?recording_mbids=<mbid>&algorithm=<algo>
  GET labs.api.listenbrainz.org/similar-artists/json
      ?artist_mbids=<mbid>&algorithm=<algo>
  algorithm=session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30
  → 200 for both. Response field names (recording_mbid/artist_mbid/
  name/score) already match the existing structs — parsing unchanged.

- Add defaultLabsBaseURL + Client.LabsBaseURL (separate from the main
  BaseURL; scrobble submission still uses api.listenbrainz.org).
- Drop the count/limit query param — result size is encoded in the
  algorithm name (limit_50); caller still applies its own top-K.
- Tests: newTestClient sets LabsBaseURL; the two *_LimitParamSet tests
  become *_MbidParamSet (assert the Labs path + mbid query param).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 15:50:32 -04:00
parent 4d2aebe3ed
commit 4fca0e66cb
4 changed files with 77 additions and 47 deletions
+3 -2
View File
@@ -29,8 +29,9 @@ func extractMBIDs(m tag.Metadata) (albumMBID, artistMBID string) {
// extractRecordingMBID reads the MusicBrainz *recording* ID — Picard's
// musicbrainz_recordingid, surfaced by dhowden/tag as mbz.Recording
// (tag display name "MusicBrainz Track Id"). This is the id ListenBrainz
// /explore/similar-recordings keys on and what tracks.mbid stores.
// (tag display name "MusicBrainz Track Id"). This is the id the
// ListenBrainz Labs similar-recordings API keys on and what tracks.mbid
// stores.
//
// Deliberately NOT mbz.Track ("MusicBrainz Release Track Id"): that is
// per-release-track, whereas similarity is per-recording. Kept separate
+54 -33
View File
@@ -17,6 +17,13 @@ import (
const defaultBaseURL = "https://api.listenbrainz.org"
// defaultLabsBaseURL is the ListenBrainz *Labs* API — a DIFFERENT host
// from the main API. Similarity datasets (similar-recordings /
// similar-artists) live here, not under api.listenbrainz.org. The old
// api.listenbrainz.org/1/explore/... paths are website routes, not API
// endpoints, and 404 for every request.
const defaultLabsBaseURL = "https://labs.api.listenbrainz.org"
// Listen is one row sent to ListenBrainz.
type Listen struct {
ListenedAt int64 // unix seconds
@@ -35,18 +42,22 @@ type Track struct {
ReleaseMBID string
}
// Client posts to the LB submit-listens endpoint.
// Client posts to the LB submit-listens endpoint and queries the Labs
// API for similarity. BaseURL is the main API; LabsBaseURL is the
// separate Labs host (see defaultLabsBaseURL).
type Client struct {
BaseURL string
HTTP *http.Client
BaseURL string
LabsBaseURL string
HTTP *http.Client
}
// NewClient returns a default-configured client (LB production base URL,
// 30s timeout).
// NewClient returns a default-configured client (LB production base
// URLs, 30s timeout).
func NewClient() *Client {
return &Client{
BaseURL: defaultBaseURL,
HTTP: &http.Client{Timeout: 30 * time.Second},
BaseURL: defaultBaseURL,
LabsBaseURL: defaultLabsBaseURL,
HTTP: &http.Client{Timeout: 30 * time.Second},
}
}
@@ -181,26 +192,31 @@ 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"
// lbSimilarRecordingsAlgorithm is a Labs-API-valid algorithm enum (the
// value is verified against labs.api.listenbrainz.org; the old
// "_session_30_…_limit_100_filter_True…" string is NOT a permitted
// member and 400s). limit_50 is baked into the name — there is no
// separate count/limit query param.
const lbSimilarRecordingsAlgorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30"
// SimilarRecording is one entry in the /explore/similar-recordings response.
// SimilarRecording is one entry in the Labs 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
// SimilarRecordings fetches similar recordings for the given recording
// MBID from the ListenBrainz Labs API. Public endpoint — no token
// required. The result count is fixed by the algorithm (limit_50); the
// caller applies its own top-K. Returns the same typed errors as
// SubmitListens.
func (c *Client) SimilarRecordings(ctx context.Context, mbid string, _ int) ([]SimilarRecording, error) {
base := c.LabsBaseURL
if base == "" {
base = defaultBaseURL
base = defaultLabsBaseURL
}
url := fmt.Sprintf("%s/1/explore/similar-recordings/%s?algorithm=%s&count=%d",
base, mbid, lbSimilarRecordingsAlgorithm, limit)
url := fmt.Sprintf("%s/similar-recordings/json?recording_mbids=%s&algorithm=%s",
base, mbid, lbSimilarRecordingsAlgorithm)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("listenbrainz: build similar-recordings request: %w", err)
@@ -234,29 +250,34 @@ func (c *Client) SimilarRecordings(ctx context.Context, mbid string, limit int)
}
}
// 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"
// lbSimilarArtistsAlgorithm is a Labs-API-valid algorithm enum (verified
// against labs.api.listenbrainz.org; shares the same permitted set as
// similar-recordings). The old value 400s. limit_50 is baked in — no
// separate count/limit query param.
const lbSimilarArtistsAlgorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30"
// SimilarArtist is one entry in the /explore/similar-artists response.
// Name is captured from the LB payload so M5c can render the artist's
// name on out-of-library suggestions without an extra MusicBrainz lookup.
// SimilarArtist is one entry in the Labs similar-artists response. Name
// is captured from the LB payload so M5c can render the artist's name
// on out-of-library suggestions without an extra MusicBrainz lookup.
// (The Labs payload also carries comment/type/gender/reference_mbid;
// those are intentionally ignored.)
type SimilarArtist struct {
MBID string `json:"artist_mbid"`
Name string `json:"name"`
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
// SimilarArtists fetches similar artists for the given artist MBID from
// the ListenBrainz Labs API. Public endpoint — no token required. The
// result count is fixed by the algorithm (limit_50); the caller applies
// its own top-K. Returns the same typed errors as SubmitListens.
func (c *Client) SimilarArtists(ctx context.Context, mbid string, _ int) ([]SimilarArtist, error) {
base := c.LabsBaseURL
if base == "" {
base = defaultBaseURL
base = defaultLabsBaseURL
}
url := fmt.Sprintf("%s/1/explore/similar-artists/%s?algorithm=%s&count=%d",
base, mbid, lbSimilarArtistsAlgorithm, limit)
url := fmt.Sprintf("%s/similar-artists/json?artist_mbids=%s&algorithm=%s",
base, mbid, lbSimilarArtistsAlgorithm)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("listenbrainz: build similar-artists request: %w", err)
+15 -7
View File
@@ -12,7 +12,7 @@ import (
func newTestClient(handler http.HandlerFunc) (*Client, *httptest.Server) {
srv := httptest.NewServer(handler)
return &Client{BaseURL: srv.URL, HTTP: srv.Client()}, srv
return &Client{BaseURL: srv.URL, LabsBaseURL: srv.URL, HTTP: srv.Client()}, srv
}
func TestClient_SubmitListens_Success(t *testing.T) {
@@ -196,7 +196,7 @@ func TestClient_SimilarRecordings_AlgorithmParamSet(t *testing.T) {
}
}
func TestClient_SimilarRecordings_LimitParamSet(t *testing.T) {
func TestClient_SimilarRecordings_MbidParamSet(t *testing.T) {
var seenURL string
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
seenURL = r.URL.String()
@@ -204,8 +204,13 @@ func TestClient_SimilarRecordings_LimitParamSet(t *testing.T) {
})
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)
// Labs API takes the MBID as a query param, not a path segment; the
// result count is fixed by the algorithm (no count/limit param).
if !strings.Contains(seenURL, "recording_mbids=abc") {
t.Errorf("URL missing recording_mbids param: %q", seenURL)
}
if !strings.Contains(seenURL, "/similar-recordings/json") {
t.Errorf("URL not the Labs similar-recordings endpoint: %q", seenURL)
}
}
@@ -291,7 +296,7 @@ func TestClient_SimilarArtists_AlgorithmParamSet(t *testing.T) {
}
}
func TestClient_SimilarArtists_LimitParamSet(t *testing.T) {
func TestClient_SimilarArtists_MbidParamSet(t *testing.T) {
var seenURL string
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
seenURL = r.URL.String()
@@ -299,8 +304,11 @@ func TestClient_SimilarArtists_LimitParamSet(t *testing.T) {
})
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)
if !strings.Contains(seenURL, "artist_mbids=abc") {
t.Errorf("URL missing artist_mbids param: %q", seenURL)
}
if !strings.Contains(seenURL, "/similar-artists/json") {
t.Errorf("URL not the Labs similar-artists endpoint: %q", seenURL)
}
}
+5 -5
View File
@@ -1,9 +1,9 @@
// Package similarity owns the inbound ListenBrainz similarity ingest
// pipeline. A periodic worker queries LB's /explore/similar-recordings
// and /explore/similar-artists endpoints for tracks the user has played,
// filters returned MBIDs to the local library, and stores the top-K
// edges in track_similarity / artist_similarity for M4c's radio
// candidate-pool builder.
// pipeline. A periodic worker queries the LB Labs API
// (labs.api.listenbrainz.org similar-recordings / similar-artists) for
// tracks the user has played, filters returned MBIDs to the local
// library, and stores the top-K edges in track_similarity /
// artist_similarity for M4c's radio candidate-pool builder.
package similarity
import (