diff --git a/internal/library/mbids.go b/internal/library/mbids.go index 55592be4..3287b012 100644 --- a/internal/library/mbids.go +++ b/internal/library/mbids.go @@ -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 diff --git a/internal/scrobble/listenbrainz/client.go b/internal/scrobble/listenbrainz/client.go index 42d3dd7a..28cfce5a 100644 --- a/internal/scrobble/listenbrainz/client.go +++ b/internal/scrobble/listenbrainz/client.go @@ -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) diff --git a/internal/scrobble/listenbrainz/client_test.go b/internal/scrobble/listenbrainz/client_test.go index e04a83f2..f8a8939e 100644 --- a/internal/scrobble/listenbrainz/client_test.go +++ b/internal/scrobble/listenbrainz/client_test.go @@ -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) } } diff --git a/internal/similarity/worker.go b/internal/similarity/worker.go index 8ce64ede..ba6ff365 100644 --- a/internal/similarity/worker.go +++ b/internal/similarity/worker.go @@ -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 (