diff --git a/SPECS.md b/SPECS.md index 64c7c9f..722baed 100644 --- a/SPECS.md +++ b/SPECS.md @@ -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) - **License:** MIT - **Module:** `git.stinnesbeck.com/nils/arr-tui` -- **Status:** M0 done +- **Status:** M1 done ## 1. Goals @@ -181,7 +181,7 @@ The file should be created with mode `0600`; warn if it is more permissive. API ## 9. Milestones - [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. - [ ] **M3** — Sonarr client and series search + add flow. - [ ] **M4** — SABnzbd client and Downloads view (queue + history) with live updates and pause/resume/delete. diff --git a/internal/arr/client.go b/internal/arr/client.go new file mode 100644 index 0000000..63d93a4 --- /dev/null +++ b/internal/arr/client.go @@ -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/. 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)])) +} diff --git a/internal/arr/types.go b/internal/arr/types.go new file mode 100644 index 0000000..bb52238 --- /dev/null +++ b/internal/arr/types.go @@ -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 +} diff --git a/internal/radarr/radarr.go b/internal/radarr/radarr.go new file mode 100644 index 0000000..92de9d1 --- /dev/null +++ b/internal/radarr/radarr.go @@ -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) } diff --git a/internal/radarr/radarr_test.go b/internal/radarr/radarr_test.go new file mode 100644 index 0000000..512ae5d --- /dev/null +++ b/internal/radarr/radarr_test.go @@ -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) + } +}