feat(scrobble): add pure ListenBrainz HTTP client with typed errors

Implements the HTTP client for ListenBrainz submit-listens with typed
sentinel errors (ErrAuth, ErrPermanent, ErrTransient, *RetryAfterError)
so the scrobble worker can branch on response semantics. All 9 httptest-
driven tests pass.
This commit is contained in:
2026-04-28 08:45:13 -04:00
parent a0e21f21b6
commit 3d23f4930c
2 changed files with 346 additions and 0 deletions
+182
View File
@@ -0,0 +1,182 @@
// Package listenbrainz is the pure HTTP client for the ListenBrainz
// submit-listens endpoint. No DB, no worker plumbing — just types and one
// method. Errors are typed (ErrAuth, ErrPermanent, ErrTransient,
// *RetryAfterError) so callers can branch on response semantics.
package listenbrainz
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"time"
)
const defaultBaseURL = "https://api.listenbrainz.org"
// Listen is one row sent to ListenBrainz.
type Listen struct {
ListenedAt int64 // unix seconds
Track Track
}
// Track is the per-listen metadata. Optional fields are omitted from the
// payload when zero-valued.
type Track struct {
ArtistName string
TrackName string
ReleaseName string
DurationMs int
RecordingMBID string
ArtistMBIDs []string
ReleaseMBID string
}
// Client posts to the LB submit-listens endpoint.
type Client struct {
BaseURL string
HTTP *http.Client
}
// NewClient returns a default-configured client (LB production base URL,
// 30s timeout).
func NewClient() *Client {
return &Client{
BaseURL: defaultBaseURL,
HTTP: &http.Client{Timeout: 30 * time.Second},
}
}
// Sentinel errors. The worker branches on these to decide retry vs fail.
var (
ErrAuth = errors.New("listenbrainz: auth rejected")
ErrPermanent = errors.New("listenbrainz: permanent error")
ErrTransient = errors.New("listenbrainz: transient error")
)
// RetryAfterError carries the LB-supplied wait duration for 429 responses.
type RetryAfterError struct{ Wait time.Duration }
func (e *RetryAfterError) Error() string {
return fmt.Sprintf("listenbrainz: retry after %v", e.Wait)
}
// SubmitListens posts the given listens. payload_type is "single" for one
// listen, "import" for batches.
func (c *Client) SubmitListens(ctx context.Context, token string, listens []Listen) error {
if len(listens) == 0 {
return nil
}
listenType := "import"
if len(listens) == 1 {
listenType = "single"
}
body, err := json.Marshal(buildPayload(listenType, listens))
if err != nil {
return fmt.Errorf("listenbrainz: marshal: %w", err)
}
base := c.BaseURL
if base == "" {
base = defaultBaseURL
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/1/submit-listens", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("listenbrainz: build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Token "+token)
httpClient := c.HTTP
if httpClient == nil {
httpClient = http.DefaultClient
}
resp, err := httpClient.Do(req)
if err != nil {
return fmt.Errorf("%w: %v", ErrTransient, err)
}
defer func() { _ = resp.Body.Close() }()
switch {
case resp.StatusCode >= 200 && resp.StatusCode < 300:
return nil
case resp.StatusCode == http.StatusUnauthorized:
return ErrAuth
case resp.StatusCode == http.StatusTooManyRequests:
wait := parseRetryAfter(resp.Header.Get("Retry-After"))
return &RetryAfterError{Wait: wait}
case resp.StatusCode >= 500:
return fmt.Errorf("%w: status %d", ErrTransient, resp.StatusCode)
default:
return fmt.Errorf("%w: status %d", ErrPermanent, resp.StatusCode)
}
}
func parseRetryAfter(header string) time.Duration {
if header == "" {
return 60 * time.Second // sensible default
}
if secs, err := strconv.Atoi(header); err == nil {
return time.Duration(secs) * time.Second
}
if t, err := http.ParseTime(header); err == nil {
d := time.Until(t)
if d < 0 {
return 60 * time.Second
}
return d
}
return 60 * time.Second
}
// payload mirrors the LB submit-listens body. Optional MBID fields use
// `omitempty` so empty strings/slices don't appear in the JSON.
type payload struct {
ListenType string `json:"listen_type"`
Payload []payloadEntry `json:"payload"`
}
type payloadEntry struct {
ListenedAt int64 `json:"listened_at"`
TrackMetadata trackMetadata `json:"track_metadata"`
}
type trackMetadata struct {
ArtistName string `json:"artist_name"`
TrackName string `json:"track_name"`
ReleaseName string `json:"release_name,omitempty"`
AdditionalInfo additionalInfo `json:"additional_info,omitempty"`
}
type additionalInfo struct {
DurationMs int `json:"duration_ms,omitempty"`
RecordingMBID string `json:"recording_mbid,omitempty"`
ArtistMBIDs []string `json:"artist_mbids,omitempty"`
ReleaseMBID string `json:"release_mbid,omitempty"`
}
func buildPayload(listenType string, listens []Listen) payload {
entries := make([]payloadEntry, 0, len(listens))
for _, l := range listens {
entries = append(entries, payloadEntry{
ListenedAt: l.ListenedAt,
TrackMetadata: trackMetadata{
ArtistName: l.Track.ArtistName,
TrackName: l.Track.TrackName,
ReleaseName: l.Track.ReleaseName,
AdditionalInfo: additionalInfo{
DurationMs: l.Track.DurationMs,
RecordingMBID: l.Track.RecordingMBID,
ArtistMBIDs: l.Track.ArtistMBIDs,
ReleaseMBID: l.Track.ReleaseMBID,
},
},
})
}
return payload{ListenType: listenType, Payload: entries}
}
@@ -0,0 +1,164 @@
package listenbrainz
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func newTestClient(handler http.HandlerFunc) (*Client, *httptest.Server) {
srv := httptest.NewServer(handler)
return &Client{BaseURL: srv.URL, HTTP: srv.Client()}, srv
}
func TestClient_SubmitListens_Success(t *testing.T) {
var seenPath, seenAuth, seenBody string
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
seenPath = r.URL.Path
seenAuth = r.Header.Get("Authorization")
buf := make([]byte, 4096)
n, _ := r.Body.Read(buf)
seenBody = string(buf[:n])
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok"}`))
})
defer srv.Close()
listens := []Listen{{
ListenedAt: 1700000000,
Track: Track{
ArtistName: "Miles Davis",
TrackName: "So What",
ReleaseName: "Kind of Blue",
DurationMs: 545000,
},
}}
if err := c.SubmitListens(context.Background(), "tk", listens); err != nil {
t.Fatalf("err: %v", err)
}
if seenPath != "/1/submit-listens" {
t.Errorf("path = %q, want /1/submit-listens", seenPath)
}
if seenAuth != "Token tk" {
t.Errorf("auth = %q, want %q", seenAuth, "Token tk")
}
if !strings.Contains(seenBody, `"track_name":"So What"`) {
t.Errorf("body missing track_name: %s", seenBody)
}
if !strings.Contains(seenBody, `"artist_name":"Miles Davis"`) {
t.Errorf("body missing artist_name: %s", seenBody)
}
}
func TestClient_SubmitListens_401_ReturnsErrAuth(t *testing.T) {
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
})
defer srv.Close()
err := c.SubmitListens(context.Background(), "bad", []Listen{{}})
if !errors.Is(err, ErrAuth) {
t.Errorf("err = %v, want ErrAuth", err)
}
}
func TestClient_SubmitListens_400_ReturnsErrPermanent(t *testing.T) {
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
})
defer srv.Close()
err := c.SubmitListens(context.Background(), "tk", []Listen{{}})
if !errors.Is(err, ErrPermanent) {
t.Errorf("err = %v, want ErrPermanent", err)
}
}
func TestClient_SubmitListens_503_ReturnsErrTransient(t *testing.T) {
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
})
defer srv.Close()
err := c.SubmitListens(context.Background(), "tk", []Listen{{}})
if !errors.Is(err, ErrTransient) {
t.Errorf("err = %v, want ErrTransient", err)
}
}
func TestClient_SubmitListens_429_ReturnsRetryAfter(t *testing.T) {
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Retry-After", "60")
w.WriteHeader(http.StatusTooManyRequests)
})
defer srv.Close()
err := c.SubmitListens(context.Background(), "tk", []Listen{{}})
var ra *RetryAfterError
if !errors.As(err, &ra) {
t.Fatalf("err = %v, want *RetryAfterError", err)
}
if ra.Wait != 60*time.Second {
t.Errorf("Wait = %v, want 60s", ra.Wait)
}
}
func TestClient_SubmitListens_NetworkFailure_ReturnsErrTransient(t *testing.T) {
c := &Client{BaseURL: "http://127.0.0.1:1", HTTP: &http.Client{Timeout: 100 * time.Millisecond}}
err := c.SubmitListens(context.Background(), "tk", []Listen{{}})
if !errors.Is(err, ErrTransient) {
t.Errorf("err = %v, want ErrTransient", err)
}
}
func TestClient_SubmitListens_PayloadTypeImportForBatch(t *testing.T) {
var seenBody string
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
buf := make([]byte, 4096)
n, _ := r.Body.Read(buf)
seenBody = string(buf[:n])
w.WriteHeader(http.StatusOK)
})
defer srv.Close()
_ = c.SubmitListens(context.Background(), "tk", []Listen{
{ListenedAt: 1, Track: Track{ArtistName: "A", TrackName: "X"}},
{ListenedAt: 2, Track: Track{ArtistName: "B", TrackName: "Y"}},
})
if !strings.Contains(seenBody, `"listen_type":"import"`) {
t.Errorf("batch body missing listen_type=import: %s", seenBody)
}
}
func TestClient_SubmitListens_PayloadTypeSingleForOne(t *testing.T) {
var seenBody string
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
buf := make([]byte, 4096)
n, _ := r.Body.Read(buf)
seenBody = string(buf[:n])
w.WriteHeader(http.StatusOK)
})
defer srv.Close()
_ = c.SubmitListens(context.Background(), "tk", []Listen{
{ListenedAt: 1, Track: Track{ArtistName: "A", TrackName: "X"}},
})
if !strings.Contains(seenBody, `"listen_type":"single"`) {
t.Errorf("single body missing listen_type=single: %s", seenBody)
}
}
func TestClient_SubmitListens_OmitsEmptyMBIDs(t *testing.T) {
var seenBody string
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
buf := make([]byte, 4096)
n, _ := r.Body.Read(buf)
seenBody = string(buf[:n])
w.WriteHeader(http.StatusOK)
})
defer srv.Close()
_ = c.SubmitListens(context.Background(), "tk", []Listen{
{ListenedAt: 1, Track: Track{ArtistName: "A", TrackName: "X"}},
})
if strings.Contains(seenBody, "recording_mbid") {
t.Errorf("body should omit empty recording_mbid: %s", seenBody)
}
}