// 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: 2 * time.Minute}, // interactive release searches query every indexer } } // 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/. 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)])) }