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:
@@ -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