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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user