Add Radarr client (M1)
Shared arr HTTP client with API error handling, plus Radarr lookup, add movie, search and quality profile / root folder / status endpoints. Tested against httptest fixtures. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
// Package arr contains the HTTP client and types shared by the Radarr and Sonarr clients.
|
||||
package arr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client is a minimal JSON client for the Radarr/Sonarr v3 API.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// NewClient returns a client for the instance at baseURL (without /api/v3).
|
||||
func NewClient(baseURL, apiKey string) *Client {
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
apiKey: apiKey,
|
||||
http: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// APIError is returned for non-2xx responses.
|
||||
type APIError struct {
|
||||
Status int
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
if e.Message == "" {
|
||||
return fmt.Sprintf("unexpected response: HTTP %d", e.Status)
|
||||
}
|
||||
return fmt.Sprintf("HTTP %d: %s", e.Status, e.Message)
|
||||
}
|
||||
|
||||
// Do sends a request to /api/v3/<path>. body (if non-nil) is JSON-encoded and
|
||||
// the response is decoded into out (if non-nil).
|
||||
func (c *Client) Do(ctx context.Context, method, path string, query url.Values, body, out any) error {
|
||||
u := c.baseURL + "/api/v3/" + strings.TrimLeft(path, "/")
|
||||
if len(query) > 0 {
|
||||
u += "?" + query.Encode()
|
||||
}
|
||||
|
||||
var rd io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encoding request: %w", err)
|
||||
}
|
||||
rd = bytes.NewReader(b)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, u, rd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("X-Api-Key", c.apiKey)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return stripURL(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
return &APIError{Status: resp.StatusCode, Message: errorMessage(resp.Body)}
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
return fmt.Errorf("decoding response: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// stripURL drops the request URL from transport errors so nothing sensitive
|
||||
// (e.g. a proxy-added key) ends up in the UI; the host stays in the message.
|
||||
func stripURL(err error) error {
|
||||
var ue *url.Error
|
||||
if errors.As(err, &ue) {
|
||||
return fmt.Errorf("%s: %w", ue.Op, ue.Err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// errorMessage extracts a readable message from Radarr/Sonarr error bodies,
|
||||
// which are either {"message": "..."} or a list of validation failures.
|
||||
func errorMessage(r io.Reader) string {
|
||||
data, _ := io.ReadAll(io.LimitReader(r, 64<<10))
|
||||
|
||||
var single struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if json.Unmarshal(data, &single) == nil && single.Message != "" {
|
||||
return single.Message
|
||||
}
|
||||
var list []struct {
|
||||
PropertyName string `json:"propertyName"`
|
||||
ErrorMessage string `json:"errorMessage"`
|
||||
}
|
||||
if json.Unmarshal(data, &list) == nil && len(list) > 0 {
|
||||
msgs := make([]string, 0, len(list))
|
||||
for _, e := range list {
|
||||
msgs = append(msgs, e.ErrorMessage)
|
||||
}
|
||||
return strings.Join(msgs, "; ")
|
||||
}
|
||||
return strings.TrimSpace(string(data[:min(len(data), 200)]))
|
||||
}
|
||||
Reference in New Issue
Block a user