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:
2026-10-20 17:39:02 +02:00
co-authored by Claude Sonnet 5.5
parent 9f05523c02
commit 2c821fd098
5 changed files with 472 additions and 2 deletions
+124
View File
@@ -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)]))
}
+54
View File
@@ -0,0 +1,54 @@
package arr
import (
"context"
"net/http"
)
// SystemStatus is the subset of /system/status we use.
type SystemStatus struct {
AppName string `json:"appName"`
Version string `json:"version"`
}
// QualityProfile is a Radarr/Sonarr quality profile.
type QualityProfile struct {
ID int `json:"id"`
Name string `json:"name"`
}
// RootFolder is a configured library root.
type RootFolder struct {
ID int `json:"id"`
Path string `json:"path"`
FreeSpace int64 `json:"freeSpace"`
Accessible bool `json:"accessible"`
}
// Image is a poster/fanart reference.
type Image struct {
CoverType string `json:"coverType"`
URL string `json:"url,omitempty"`
RemoteURL string `json:"remoteUrl,omitempty"`
}
// SystemStatus fetches /system/status.
func (c *Client) SystemStatus(ctx context.Context) (SystemStatus, error) {
var s SystemStatus
err := c.Do(ctx, http.MethodGet, "system/status", nil, nil, &s)
return s, err
}
// QualityProfiles lists quality profiles.
func (c *Client) QualityProfiles(ctx context.Context) ([]QualityProfile, error) {
var p []QualityProfile
err := c.Do(ctx, http.MethodGet, "qualityprofile", nil, nil, &p)
return p, err
}
// RootFolders lists root folders.
func (c *Client) RootFolders(ctx context.Context) ([]RootFolder, error) {
var f []RootFolder
err := c.Do(ctx, http.MethodGet, "rootfolder", nil, nil, &f)
return f, err
}