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
+2 -2
View File
@@ -6,7 +6,7 @@ A terminal UI to search for movies and series, add them to Radarr / Sonarr, and
- **TUI library:** [tview](https://github.com/rivo/tview) (on top of tcell) - **TUI library:** [tview](https://github.com/rivo/tview) (on top of tcell)
- **License:** MIT - **License:** MIT
- **Module:** `git.stinnesbeck.com/nils/arr-tui` - **Module:** `git.stinnesbeck.com/nils/arr-tui`
- **Status:** M0 done - **Status:** M1 done
## 1. Goals ## 1. Goals
@@ -181,7 +181,7 @@ The file should be created with mode `0600`; warn if it is more permissive. API
## 9. Milestones ## 9. Milestones
- [x] **M0** — Project skeleton: `go mod init`, config loading, empty tview app with tabs and status bar. - [x] **M0** — Project skeleton: `go mod init`, config loading, empty tview app with tabs and status bar.
- [ ] **M1** — Radarr client: system status, lookup, quality profiles, root folders, add movie. - [x] **M1** — Radarr client: system status, lookup, quality profiles, root folders, add movie.
- [ ] **M2** — Movie search + add flow in the UI. - [ ] **M2** — Movie search + add flow in the UI.
- [ ] **M3** — Sonarr client and series search + add flow. - [ ] **M3** — Sonarr client and series search + add flow.
- [ ] **M4** — SABnzbd client and Downloads view (queue + history) with live updates and pause/resume/delete. - [ ] **M4** — SABnzbd client and Downloads view (queue + history) with live updates and pause/resume/delete.
+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
}
+142
View File
@@ -0,0 +1,142 @@
// Package radarr is a client for the Radarr v3 API.
package radarr
import (
"context"
"net/http"
"net/url"
"strconv"
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
)
// Client talks to a Radarr instance. The embedded arr.Client provides
// SystemStatus, QualityProfiles and RootFolders.
type Client struct {
*arr.Client
}
// NewClient returns a Radarr client.
func NewClient(baseURL, apiKey string) *Client {
return &Client{arr.NewClient(baseURL, apiKey)}
}
// Ratings holds the per-source ratings Radarr reports.
type Ratings struct {
IMDB *Rating `json:"imdb,omitempty"`
TMDB *Rating `json:"tmdb,omitempty"`
Metacritic *Rating `json:"metacritic,omitempty"`
RottenTomatoes *Rating `json:"rottenTomatoes,omitempty"`
}
// Rating is a single rating value.
type Rating struct {
Votes int `json:"votes"`
Value float64 `json:"value"`
}
// Movie is a lookup result or library movie. ID is 0 when the movie is not
// in the library yet.
type Movie struct {
ID int `json:"id"`
Title string `json:"title"`
OriginalTitle string `json:"originalTitle,omitempty"`
Year int `json:"year"`
Overview string `json:"overview,omitempty"`
Runtime int `json:"runtime"`
Genres []string `json:"genres,omitempty"`
Studio string `json:"studio,omitempty"`
Status string `json:"status,omitempty"`
TMDBID int `json:"tmdbId"`
IMDBID string `json:"imdbId,omitempty"`
TitleSlug string `json:"titleSlug,omitempty"`
Images []arr.Image `json:"images,omitempty"`
Ratings Ratings `json:"ratings"`
Monitored bool `json:"monitored"`
HasFile bool `json:"hasFile"`
QualityProfileID int `json:"qualityProfileId"`
}
// InLibrary reports whether the movie has already been added to Radarr.
func (m Movie) InLibrary() bool { return m.ID != 0 }
// Rating returns the best available rating (IMDb, then TMDb), or 0.
func (m Movie) Rating() float64 {
switch {
case m.Ratings.IMDB != nil && m.Ratings.IMDB.Value > 0:
return m.Ratings.IMDB.Value
case m.Ratings.TMDB != nil:
return m.Ratings.TMDB.Value
}
return 0
}
// Lookup searches for movies by free text ("The Matrix"), "tmdb:603" or "imdb:tt0133093".
func (c *Client) Lookup(ctx context.Context, term string) ([]Movie, error) {
var movies []Movie
err := c.Do(ctx, http.MethodGet, "movie/lookup", url.Values{"term": {term}}, nil, &movies)
return movies, err
}
// Movies lists the movies in the library.
func (c *Client) Movies(ctx context.Context) ([]Movie, error) {
var movies []Movie
err := c.Do(ctx, http.MethodGet, "movie", nil, nil, &movies)
return movies, err
}
// AddOptions are the choices from the add form.
type AddOptions struct {
QualityProfileID int
RootFolderPath string
Monitored bool
SearchNow bool
MinimumAvailability string // "announced", "inCinemas" or "released"; defaults to "released"
}
type addMovieRequest struct {
Title string `json:"title"`
Year int `json:"year"`
TMDBID int `json:"tmdbId"`
TitleSlug string `json:"titleSlug,omitempty"`
Images []arr.Image `json:"images,omitempty"`
QualityProfileID int `json:"qualityProfileId"`
RootFolderPath string `json:"rootFolderPath"`
Monitored bool `json:"monitored"`
MinimumAvailability string `json:"minimumAvailability"`
AddOptions struct {
SearchForMovie bool `json:"searchForMovie"`
} `json:"addOptions"`
}
// AddMovie adds a lookup result to Radarr and returns the created movie.
func (c *Client) AddMovie(ctx context.Context, m Movie, opts AddOptions) (Movie, error) {
req := addMovieRequest{
Title: m.Title,
Year: m.Year,
TMDBID: m.TMDBID,
TitleSlug: m.TitleSlug,
Images: m.Images,
QualityProfileID: opts.QualityProfileID,
RootFolderPath: opts.RootFolderPath,
Monitored: opts.Monitored,
MinimumAvailability: opts.MinimumAvailability,
}
if req.MinimumAvailability == "" {
req.MinimumAvailability = "released"
}
req.AddOptions.SearchForMovie = opts.SearchNow
var created Movie
err := c.Do(ctx, http.MethodPost, "movie", nil, req, &created)
return created, err
}
// SearchMovie triggers an automatic search for an existing library movie.
func (c *Client) SearchMovie(ctx context.Context, movieID int) error {
body := map[string]any{"name": "MoviesSearch", "movieIds": []int{movieID}}
return c.Do(ctx, http.MethodPost, "command", nil, body, nil)
}
// TMDBTerm builds a lookup term for a TMDb id.
func TMDBTerm(tmdbID int) string { return "tmdb:" + strconv.Itoa(tmdbID) }
+150
View File
@@ -0,0 +1,150 @@
package radarr
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
)
const lookupJSON = `[
{"id":0,"title":"The Matrix","year":1999,"tmdbId":603,"imdbId":"tt0133093","titleSlug":"the-matrix-603",
"runtime":136,"genres":["Action","Science Fiction"],"overview":"A hacker...",
"images":[{"coverType":"poster","remoteUrl":"https://img/p.jpg"}],
"ratings":{"imdb":{"votes":2000000,"value":8.7},"tmdb":{"votes":1,"value":8.2}}},
{"id":42,"title":"The Matrix Reloaded","year":2003,"tmdbId":604,"ratings":{"tmdb":{"votes":1,"value":7.0}}}
]`
func newTestClient(t *testing.T, h http.HandlerFunc) *Client {
t.Helper()
srv := httptest.NewServer(h)
t.Cleanup(srv.Close)
return NewClient(srv.URL, "secret")
}
func TestLookup(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v3/movie/lookup" || r.URL.Query().Get("term") != "the matrix" {
t.Errorf("unexpected request %s", r.URL)
}
if r.Header.Get("X-Api-Key") != "secret" {
t.Error("missing api key header")
}
w.Write([]byte(lookupJSON))
})
movies, err := c.Lookup(context.Background(), "the matrix")
if err != nil {
t.Fatal(err)
}
if len(movies) != 2 {
t.Fatalf("got %d movies", len(movies))
}
if movies[0].InLibrary() || !movies[1].InLibrary() {
t.Error("InLibrary wrong")
}
if movies[0].Rating() != 8.7 || movies[1].Rating() != 7.0 {
t.Errorf("ratings: %v %v", movies[0].Rating(), movies[1].Rating())
}
if movies[0].Images[0].RemoteURL == "" {
t.Error("image not decoded")
}
}
func TestAddMovie(t *testing.T) {
var got map[string]any
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/api/v3/movie" {
t.Errorf("unexpected request %s %s", r.Method, r.URL)
}
json.NewDecoder(r.Body).Decode(&got)
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{"id":7,"title":"The Matrix","tmdbId":603}`))
})
m := Movie{Title: "The Matrix", Year: 1999, TMDBID: 603, TitleSlug: "the-matrix-603"}
created, err := c.AddMovie(context.Background(), m, AddOptions{
QualityProfileID: 4, RootFolderPath: "/movies", Monitored: true, SearchNow: true,
})
if err != nil {
t.Fatal(err)
}
if created.ID != 7 {
t.Errorf("id = %d", created.ID)
}
if got["tmdbId"] != float64(603) || got["qualityProfileId"] != float64(4) ||
got["rootFolderPath"] != "/movies" || got["monitored"] != true ||
got["minimumAvailability"] != "released" {
t.Errorf("request body = %v", got)
}
if ao, _ := got["addOptions"].(map[string]any); ao["searchForMovie"] != true {
t.Errorf("addOptions = %v", got["addOptions"])
}
}
func TestAPIErrors(t *testing.T) {
tests := map[string]struct {
status int
body string
want string
}{
"message": {http.StatusBadRequest, `{"message":"bad thing"}`, "HTTP 400: bad thing"},
"validation": {http.StatusBadRequest, `[{"propertyName":"TmdbId","errorMessage":"This movie has already been added"}]`, "HTTP 400: This movie has already been added"},
"unauth": {http.StatusUnauthorized, ``, "unexpected response: HTTP 401"},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tc.status)
w.Write([]byte(tc.body))
})
_, err := c.AddMovie(context.Background(), Movie{}, AddOptions{})
var apiErr *arr.APIError
if !errors.As(err, &apiErr) || apiErr.Status != tc.status || err.Error() != tc.want {
t.Fatalf("err = %v, want %q", err, tc.want)
}
})
}
}
func TestSharedEndpoints(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v3/system/status":
w.Write([]byte(`{"appName":"Radarr","version":"5.0.0"}`))
case "/api/v3/qualityprofile":
w.Write([]byte(`[{"id":1,"name":"Any"},{"id":4,"name":"HD-1080p"}]`))
case "/api/v3/rootfolder":
w.Write([]byte(`[{"id":1,"path":"/movies","freeSpace":1000,"accessible":true}]`))
default:
t.Errorf("unexpected %s", r.URL)
}
})
ctx := context.Background()
if s, err := c.SystemStatus(ctx); err != nil || s.AppName != "Radarr" {
t.Errorf("status = %+v, %v", s, err)
}
if p, err := c.QualityProfiles(ctx); err != nil || len(p) != 2 || p[1].Name != "HD-1080p" {
t.Errorf("profiles = %+v, %v", p, err)
}
if f, err := c.RootFolders(ctx); err != nil || len(f) != 1 || f[0].Path != "/movies" {
t.Errorf("folders = %+v, %v", f, err)
}
}
func TestSearchMovie(t *testing.T) {
var got map[string]any
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
json.NewDecoder(r.Body).Decode(&got)
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{}`))
})
if err := c.SearchMovie(context.Background(), 7); err != nil {
t.Fatal(err)
}
if got["name"] != "MoviesSearch" {
t.Errorf("body = %v", got)
}
}