diff --git a/internal/scrobble/listenbrainz/client.go b/internal/scrobble/listenbrainz/client.go new file mode 100644 index 00000000..949cae5b --- /dev/null +++ b/internal/scrobble/listenbrainz/client.go @@ -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} +} diff --git a/internal/scrobble/listenbrainz/client_test.go b/internal/scrobble/listenbrainz/client_test.go new file mode 100644 index 00000000..0bca4639 --- /dev/null +++ b/internal/scrobble/listenbrainz/client_test.go @@ -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) + } +}