Without a config file arr-tui refused to start. It now creates an empty one (private, with its directory) and opens the Config view. A service with neither address nor key is "not configured", which validation allows: its client returns ErrNotConfigured without a request, its indicator stays grey, it is logged once as information, and its lists say so instead of showing errors. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
151 lines
3.9 KiB
Go
151 lines
3.9 KiB
Go
// 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"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Client is a minimal JSON client for the Radarr/Sonarr v3 API.
|
|
type Client struct {
|
|
mu sync.RWMutex // guards baseURL and apiKey, which Reconfigure can change
|
|
baseURL string
|
|
apiKey string
|
|
http *http.Client
|
|
}
|
|
|
|
// settings returns the current base URL and API key.
|
|
func (c *Client) settings() (baseURL, apiKey string) {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.baseURL, c.apiKey
|
|
}
|
|
|
|
// Reconfigure points the client at another instance or key. Requests already
|
|
// in flight are not affected.
|
|
func (c *Client) Reconfigure(baseURL, apiKey string) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.baseURL = strings.TrimRight(baseURL, "/")
|
|
c.apiKey = apiKey
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
// ErrNotConfigured is returned without a request while the client has no
|
|
// address: the service is not set up.
|
|
var ErrNotConfigured = errors.New("not configured: enter its address and API key in the Config view (F6)")
|
|
|
|
// 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 {
|
|
base, apiKey := c.settings()
|
|
if base == "" {
|
|
return ErrNotConfigured
|
|
}
|
|
u := base + "/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", 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)]))
|
|
}
|