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