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