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:
@@ -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.
|
||||||
|
|||||||
@@ -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)]))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user