Add Sonarr client and series search/add flow (M3)

Sonarr client with lookup, add series (monitor modes) and search.
Refactor the movie view into a generic search view driven by a backend
interface so Movies and Series share one implementation.
Add F1-F4 / Alt+1-4 view switching that works while typing.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 17:49:11 +02:00
co-authored by Claude Sonnet 5.5
parent 51fb0556b0
commit 2dd7c17737
8 changed files with 631 additions and 68 deletions
+3 -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)
- **License:** MIT
- **Module:** `git.stinnesbeck.com/nils/arr-tui`
- **Status:** M2 done
- **Status:** M3 done
## 1. Goals
@@ -101,6 +101,7 @@ Keybindings (initial proposal):
| Key | Action |
|---|---|
| `Tab` / `1-4` | Switch view |
| `F1-F4` / `Alt+1-4` | Switch view, also while typing in a search box |
| `/` | Focus search |
| `Enter` | Search / open details / confirm |
| `a` | Add selected result |
@@ -183,7 +184,7 @@ The file should be created with mode `0600`; warn if it is more permissive. API
- [x] **M0** — Project skeleton: `go mod init`, config loading, empty tview app with tabs and status bar.
- [x] **M1** — Radarr client: system status, lookup, quality profiles, root folders, add movie.
- [x] **M2** — Movie search + add flow in the UI.
- [ ] **M3** — Sonarr client and series search + add flow.
- [x] **M3** — Sonarr client and series search + add flow.
- [ ] **M4** — SABnzbd client and Downloads view (queue + history) with live updates and pause/resume/delete.
- [ ] **M5** — Radarr/Sonarr queues merged into the Downloads view.
- [ ] **M6** — Interactive release search (movies, then series seasons/episodes) and grab.
+163
View File
@@ -0,0 +1,163 @@
// Package sonarr is a client for the Sonarr v3/v4 API.
package sonarr
import (
"context"
"net/http"
"net/url"
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
)
// Client talks to a Sonarr instance. The embedded arr.Client provides
// SystemStatus, QualityProfiles and RootFolders.
type Client struct {
*arr.Client
}
// NewClient returns a Sonarr client.
func NewClient(baseURL, apiKey string) *Client {
return &Client{arr.NewClient(baseURL, apiKey)}
}
// Monitor modes accepted by addOptions.monitor.
const (
MonitorAll = "all"
MonitorFuture = "future"
MonitorMissing = "missing"
MonitorExisting = "existing"
MonitorFirstSeason = "firstSeason"
MonitorLatestSeason = "latestSeason"
MonitorPilot = "pilot"
MonitorNone = "none"
)
// Rating is a series rating.
type Rating struct {
Votes int `json:"votes"`
Value float64 `json:"value"`
}
// Season is a season entry of a series.
type Season struct {
SeasonNumber int `json:"seasonNumber"`
Monitored bool `json:"monitored"`
}
// Statistics are the episode counts Sonarr reports for library series.
type Statistics struct {
SeasonCount int `json:"seasonCount"`
EpisodeFileCount int `json:"episodeFileCount"`
EpisodeCount int `json:"episodeCount"`
TotalEpisodeCount int `json:"totalEpisodeCount"`
}
// Series is a lookup result or library series. ID is 0 when the series is
// not in the library yet.
type Series struct {
ID int `json:"id"`
Title string `json:"title"`
Year int `json:"year"`
Overview string `json:"overview,omitempty"`
Network string `json:"network,omitempty"`
Status string `json:"status,omitempty"`
Runtime int `json:"runtime"`
Genres []string `json:"genres,omitempty"`
TVDBID int `json:"tvdbId"`
TMDBID int `json:"tmdbId,omitempty"`
IMDBID string `json:"imdbId,omitempty"`
TitleSlug string `json:"titleSlug,omitempty"`
SeriesType string `json:"seriesType,omitempty"`
Images []arr.Image `json:"images,omitempty"`
Ratings Rating `json:"ratings"`
Seasons []Season `json:"seasons,omitempty"`
Monitored bool `json:"monitored"`
Statistics Statistics `json:"statistics"`
}
// InLibrary reports whether the series has already been added to Sonarr.
func (s Series) InLibrary() bool { return s.ID != 0 }
// SeasonCount returns the number of regular seasons (excluding specials).
func (s Series) SeasonCount() int {
n := 0
for _, season := range s.Seasons {
if season.SeasonNumber > 0 {
n++
}
}
return n
}
// Lookup searches for series by free text, "tvdb:81189" or "imdb:tt0903747".
func (c *Client) Lookup(ctx context.Context, term string) ([]Series, error) {
var series []Series
err := c.Do(ctx, http.MethodGet, "series/lookup", url.Values{"term": {term}}, nil, &series)
return series, err
}
// Series lists the series in the library.
func (c *Client) Series(ctx context.Context) ([]Series, error) {
var series []Series
err := c.Do(ctx, http.MethodGet, "series", nil, nil, &series)
return series, err
}
// AddOptions are the choices from the add form.
type AddOptions struct {
QualityProfileID int
RootFolderPath string
Monitored bool
Monitor string // one of the Monitor* constants; defaults to MonitorAll
SearchNow bool // search for missing episodes after adding
}
type addSeriesRequest struct {
Title string `json:"title"`
Year int `json:"year"`
TVDBID int `json:"tvdbId"`
TitleSlug string `json:"titleSlug,omitempty"`
SeriesType string `json:"seriesType,omitempty"`
Images []arr.Image `json:"images,omitempty"`
Seasons []Season `json:"seasons,omitempty"`
QualityProfileID int `json:"qualityProfileId"`
RootFolderPath string `json:"rootFolderPath"`
Monitored bool `json:"monitored"`
SeasonFolder bool `json:"seasonFolder"`
AddOptions struct {
Monitor string `json:"monitor"`
SearchForMissingEpisodes bool `json:"searchForMissingEpisodes"`
} `json:"addOptions"`
}
// AddSeries adds a lookup result to Sonarr and returns the created series.
func (c *Client) AddSeries(ctx context.Context, s Series, opts AddOptions) (Series, error) {
req := addSeriesRequest{
Title: s.Title,
Year: s.Year,
TVDBID: s.TVDBID,
TitleSlug: s.TitleSlug,
SeriesType: s.SeriesType,
Images: s.Images,
Seasons: s.Seasons,
QualityProfileID: opts.QualityProfileID,
RootFolderPath: opts.RootFolderPath,
Monitored: opts.Monitored,
SeasonFolder: true,
}
req.AddOptions.Monitor = opts.Monitor
if req.AddOptions.Monitor == "" {
req.AddOptions.Monitor = MonitorAll
}
req.AddOptions.SearchForMissingEpisodes = opts.SearchNow
var created Series
err := c.Do(ctx, http.MethodPost, "series", nil, req, &created)
return created, err
}
// SearchSeries triggers an automatic search for all missing episodes of a library series.
func (c *Client) SearchSeries(ctx context.Context, seriesID int) error {
body := map[string]any{"name": "SeriesSearch", "seriesId": seriesID}
return c.Do(ctx, http.MethodPost, "command", nil, body, nil)
}
+117
View File
@@ -0,0 +1,117 @@
package sonarr
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
const lookupJSON = `[
{"id":0,"title":"Breaking Bad","year":2008,"tvdbId":81189,"titleSlug":"breaking-bad","network":"AMC",
"status":"ended","runtime":47,"genres":["Crime","Drama"],"seriesType":"standard",
"ratings":{"votes":100,"value":9.3},
"seasons":[{"seasonNumber":0,"monitored":false},{"seasonNumber":1,"monitored":false},{"seasonNumber":2,"monitored":false}],
"images":[{"coverType":"poster","remoteUrl":"https://img/p.jpg"}]},
{"id":12,"title":"Better Call Saul","year":2015,"tvdbId":273181}
]`
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/series/lookup" || r.URL.Query().Get("term") != "breaking" {
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))
})
series, err := c.Lookup(context.Background(), "breaking")
if err != nil {
t.Fatal(err)
}
if len(series) != 2 || series[0].InLibrary() || !series[1].InLibrary() {
t.Fatalf("series = %+v", series)
}
if series[0].SeasonCount() != 2 {
t.Errorf("SeasonCount = %d, want 2 (specials excluded)", series[0].SeasonCount())
}
if series[0].Ratings.Value != 9.3 || series[0].Network != "AMC" {
t.Errorf("fields not decoded: %+v", series[0])
}
}
func TestAddSeries(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/series" {
t.Errorf("unexpected request %s %s", r.Method, r.URL)
}
json.NewDecoder(r.Body).Decode(&got)
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{"id":3,"title":"Breaking Bad","tvdbId":81189}`))
})
s := Series{
Title: "Breaking Bad", Year: 2008, TVDBID: 81189, SeriesType: "standard",
Seasons: []Season{{SeasonNumber: 1}, {SeasonNumber: 2}},
}
created, err := c.AddSeries(context.Background(), s, AddOptions{
QualityProfileID: 4, RootFolderPath: "/tv", Monitored: true,
Monitor: MonitorFirstSeason, SearchNow: true,
})
if err != nil {
t.Fatal(err)
}
if created.ID != 3 {
t.Errorf("id = %d", created.ID)
}
if got["tvdbId"] != float64(81189) || got["qualityProfileId"] != float64(4) ||
got["rootFolderPath"] != "/tv" || got["monitored"] != true || got["seasonFolder"] != true {
t.Errorf("request body = %v", got)
}
ao, _ := got["addOptions"].(map[string]any)
if ao["monitor"] != "firstSeason" || ao["searchForMissingEpisodes"] != true {
t.Errorf("addOptions = %v", ao)
}
if seasons, _ := got["seasons"].([]any); len(seasons) != 2 {
t.Errorf("seasons = %v", got["seasons"])
}
}
func TestAddSeriesDefaultMonitor(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.AddSeries(context.Background(), Series{}, AddOptions{}); err != nil {
t.Fatal(err)
}
if ao, _ := got["addOptions"].(map[string]any); ao["monitor"] != "all" {
t.Errorf("addOptions = %v", ao)
}
}
func TestSearchSeries(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.SearchSeries(context.Background(), 3); err != nil {
t.Fatal(err)
}
if got["name"] != "SeriesSearch" || got["seriesId"] != float64(3) {
t.Errorf("body = %v", got)
}
}
+27 -3
View File
@@ -8,6 +8,8 @@ import (
"github.com/rivo/tview"
"git.stinnesbeck.com/nils/arr-tui/internal/config"
"git.stinnesbeck.com/nils/arr-tui/internal/radarr"
"git.stinnesbeck.com/nils/arr-tui/internal/sonarr"
)
const modalPage = "modal"
@@ -42,8 +44,8 @@ func New(cfg *config.Config) *App {
}
a.views = []view{
{'1', "Movies", newMoviesView(a, cfg.Radarr).root},
{'2', "Series", placeholder("Series search (M3)")},
{'1', "Movies", newSearchView(a, radarrBackend{radarr.NewClient(cfg.Radarr.URL, cfg.Radarr.APIKey)}, cfg.Radarr).root},
{'2', "Series", newSearchView(a, sonarrBackend{sonarr.NewClient(cfg.Sonarr.URL, cfg.Sonarr.APIKey)}, cfg.Sonarr).root},
{'3', "Downloads", placeholder("Downloads (M4/M5)")},
{'4', "Library", placeholder("Library (v0.2)")},
}
@@ -81,7 +83,7 @@ func (a *App) Run() error {
}
func (a *App) renderStatus() {
hints := "[gray]q quit · Tab/1-4 views"
hints := "[gray]q quit · Tab/1-4/F1-F4 views · / search"
if a.message == "" {
a.status.SetText(hints)
return
@@ -128,6 +130,13 @@ func (a *App) handleKey(ev *tcell.EventKey) *tcell.EventKey {
a.app.Stop()
return nil
}
// View switching that also works while typing in a search box. Not while a dialog is open.
if !a.modalOpen {
if i := a.viewShortcut(ev); i >= 0 && i < len(a.views) {
a.switchTo(i)
return nil
}
}
if a.typing() {
return ev
}
@@ -153,6 +162,21 @@ func (a *App) handleKey(ev *tcell.EventKey) *tcell.EventKey {
return ev
}
// viewShortcut returns the view index for F1-F4 or Alt+1-4, or -1.
func (a *App) viewShortcut(ev *tcell.EventKey) int {
if ev.Key() >= tcell.KeyF1 && ev.Key() <= tcell.KeyF12 {
return int(ev.Key() - tcell.KeyF1)
}
if ev.Key() == tcell.KeyRune && ev.Modifiers()&tcell.ModAlt != 0 {
for i, v := range a.views {
if ev.Rune() == v.key {
return i
}
}
}
return -1
}
func (a *App) switchTo(i int) {
a.active = i
a.pages.SwitchToPage(a.views[i].name)
+135
View File
@@ -0,0 +1,135 @@
package ui
import (
"context"
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
"git.stinnesbeck.com/nils/arr-tui/internal/radarr"
"git.stinnesbeck.com/nils/arr-tui/internal/sonarr"
)
// result is one search hit, independent of which *arr produced it.
type result interface {
key() string // stable identity, e.g. "tmdb:603"
title() string
label() string
details() string
inLibrary() bool
withID(id int) result // copy flagged as added to the library
}
// monitorMode is a Sonarr-style "which episodes to monitor" choice.
type monitorMode struct {
label string
value string
}
// addChoice is what the add dialog collects.
type addChoice struct {
profileID int
folder string
monitored bool
searchNow bool
monitor string // empty for backends without monitor modes
}
// backend adapts Radarr or Sonarr to the generic search view.
type backend interface {
name() string // "Radarr"
noun() string // "Movies"
arr() *arr.Client
search(ctx context.Context, term string) ([]result, error)
monitorModes() []monitorMode // nil if not applicable
add(ctx context.Context, r result, c addChoice) (id int, err error)
}
// --- Radarr ---
type movieResult struct{ radarr.Movie }
func (m movieResult) key() string { return radarr.TMDBTerm(m.TMDBID) }
func (m movieResult) title() string { return m.Title }
func (m movieResult) label() string { return movieLabel(m.Movie) }
func (m movieResult) details() string { return movieDetails(m.Movie) }
func (m movieResult) inLibrary() bool { return m.InLibrary() }
func (m movieResult) withID(id int) result {
m.ID = id
return m
}
type radarrBackend struct{ c *radarr.Client }
func (b radarrBackend) name() string { return "Radarr" }
func (b radarrBackend) noun() string { return "Movies" }
func (b radarrBackend) arr() *arr.Client { return b.c.Client }
func (radarrBackend) monitorModes() []monitorMode { return nil }
func (b radarrBackend) search(ctx context.Context, term string) ([]result, error) {
movies, err := b.c.Lookup(ctx, term)
out := make([]result, len(movies))
for i, m := range movies {
out[i] = movieResult{m}
}
return out, err
}
func (b radarrBackend) add(ctx context.Context, r result, c addChoice) (int, error) {
created, err := b.c.AddMovie(ctx, r.(movieResult).Movie, radarr.AddOptions{
QualityProfileID: c.profileID,
RootFolderPath: c.folder,
Monitored: c.monitored,
SearchNow: c.searchNow,
})
return created.ID, err
}
// --- Sonarr ---
type seriesResult struct{ sonarr.Series }
func (s seriesResult) key() string { return "tvdb:" + itoa(s.TVDBID) }
func (s seriesResult) title() string { return s.Title }
func (s seriesResult) label() string { return seriesLabel(s.Series) }
func (s seriesResult) details() string { return seriesDetails(s.Series) }
func (s seriesResult) inLibrary() bool { return s.InLibrary() }
func (s seriesResult) withID(id int) result {
s.ID = id
return s
}
type sonarrBackend struct{ c *sonarr.Client }
func (b sonarrBackend) name() string { return "Sonarr" }
func (b sonarrBackend) noun() string { return "Series" }
func (b sonarrBackend) arr() *arr.Client { return b.c.Client }
func (sonarrBackend) monitorModes() []monitorMode {
return []monitorMode{
{"All episodes", sonarr.MonitorAll},
{"Future episodes", sonarr.MonitorFuture},
{"Missing episodes", sonarr.MonitorMissing},
{"First season", sonarr.MonitorFirstSeason},
{"Latest season", sonarr.MonitorLatestSeason},
{"None", sonarr.MonitorNone},
}
}
func (b sonarrBackend) search(ctx context.Context, term string) ([]result, error) {
series, err := b.c.Lookup(ctx, term)
out := make([]result, len(series))
for i, s := range series {
out[i] = seriesResult{s}
}
return out, err
}
func (b sonarrBackend) add(ctx context.Context, r result, c addChoice) (int, error) {
created, err := b.c.AddSeries(ctx, r.(seriesResult).Series, sonarr.AddOptions{
QualityProfileID: c.profileID,
RootFolderPath: c.folder,
Monitored: c.monitored,
Monitor: c.monitor,
SearchNow: c.searchNow,
})
return created.ID, err
}
+68
View File
@@ -2,12 +2,14 @@ package ui
import (
"fmt"
"strconv"
"strings"
"github.com/rivo/tview"
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
"git.stinnesbeck.com/nils/arr-tui/internal/radarr"
"git.stinnesbeck.com/nils/arr-tui/internal/sonarr"
)
// movieLabel is the one-line list entry for a movie.
@@ -81,3 +83,69 @@ func folderIndex(folders []arr.RootFolder, path string) int {
}
return 0
}
func itoa(n int) string { return strconv.Itoa(n) }
// seriesLabel is the one-line list entry for a series.
func seriesLabel(s sonarr.Series) string {
label := tview.Escape(s.Title)
if s.Year > 0 {
label += fmt.Sprintf(" (%d)", s.Year)
}
if s.InLibrary() {
label += " [green]✓[-]"
}
return label
}
// seriesDetails renders the detail pane text for a series.
func seriesDetails(s sonarr.Series) string {
var b strings.Builder
title := tview.Escape(s.Title)
if s.Year > 0 {
title += fmt.Sprintf(" (%d)", s.Year)
}
fmt.Fprintf(&b, "[::b]%s[::-]\n", title)
var facts []string
if s.Ratings.Value > 0 {
facts = append(facts, fmt.Sprintf("★ %.1f", s.Ratings.Value))
}
if n := s.SeasonCount(); n > 0 {
facts = append(facts, fmt.Sprintf("%d season%s", n, plural(n)))
}
if s.Runtime > 0 {
facts = append(facts, fmt.Sprintf("%d min", s.Runtime))
}
if len(s.Genres) > 0 {
facts = append(facts, tview.Escape(strings.Join(s.Genres, ", ")))
}
if len(facts) > 0 {
b.WriteString(strings.Join(facts, " · ") + "\n")
}
if s.Network != "" {
fmt.Fprintf(&b, "Network: %s\n", tview.Escape(s.Network))
}
if s.Status != "" {
fmt.Fprintf(&b, "Status: %s\n", tview.Escape(s.Status))
}
if s.InLibrary() {
st := s.Statistics
if st.EpisodeCount > 0 && st.EpisodeFileCount >= st.EpisodeCount {
b.WriteString("[green]In library, all episodes downloaded[-]\n")
} else {
fmt.Fprintf(&b, "[yellow]In library, %d/%d episodes downloaded[-]\n", st.EpisodeFileCount, st.EpisodeCount)
}
}
if s.Overview != "" {
b.WriteString("\n" + tview.Escape(s.Overview) + "\n")
}
return b.String()
}
func plural(n int) string {
if n == 1 {
return ""
}
return "s"
}
+43
View File
@@ -6,6 +6,7 @@ import (
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
"git.stinnesbeck.com/nils/arr-tui/internal/radarr"
"git.stinnesbeck.com/nils/arr-tui/internal/sonarr"
)
func TestMovieLabel(t *testing.T) {
@@ -43,3 +44,45 @@ func TestDefaultIndexes(t *testing.T) {
t.Error("folderIndex")
}
}
func TestSeriesLabelAndDetails(t *testing.T) {
s := sonarr.Series{
Title: "Breaking Bad", Year: 2008, Network: "AMC", Status: "ended", Runtime: 47,
Genres: []string{"Crime"},
Ratings: sonarr.Rating{Value: 9.3},
Seasons: []sonarr.Season{{SeasonNumber: 0}, {SeasonNumber: 1}},
}
if got := seriesLabel(s); got != "Breaking Bad (2008)" {
t.Errorf("label = %q", got)
}
got := seriesDetails(s)
for _, want := range []string{"Breaking Bad (2008)", "★ 9.3", "1 season ·", "47 min", "Network: AMC", "Status: ended"} {
if !strings.Contains(got, want) {
t.Errorf("details missing %q:\n%s", want, got)
}
}
s.ID = 1
s.Statistics = sonarr.Statistics{EpisodeCount: 10, EpisodeFileCount: 4}
if got := seriesDetails(s); !strings.Contains(got, "4/10 episodes") {
t.Errorf("progress missing:\n%s", got)
}
s.Statistics.EpisodeFileCount = 10
if got := seriesDetails(s); !strings.Contains(got, "all episodes downloaded") {
t.Errorf("complete marker missing:\n%s", got)
}
}
func TestResultKeysAndWithID(t *testing.T) {
var r result = movieResult{radarr.Movie{TMDBID: 603, Title: "x"}}
if r.key() != "tmdb:603" || r.inLibrary() {
t.Fatalf("movie key/inLibrary: %q %v", r.key(), r.inLibrary())
}
if added := r.withID(5); !added.inLibrary() || r.inLibrary() {
t.Error("withID must return a flagged copy and leave the original alone")
}
r = seriesResult{sonarr.Series{TVDBID: 81189}}
if r.key() != "tvdb:81189" {
t.Errorf("series key = %q", r.key())
}
}
+75 -63
View File
@@ -13,7 +13,6 @@ import (
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
"git.stinnesbeck.com/nils/arr-tui/internal/config"
"git.stinnesbeck.com/nils/arr-tui/internal/radarr"
)
const (
@@ -21,11 +20,11 @@ const (
minAutoSearch = 2 // characters before the debounced search kicks in
)
// moviesView is the movie search / add screen backed by Radarr.
type moviesView struct {
app *App
client *radarr.Client
cfg config.Arr
// searchView is the search / add screen for one backend (Radarr or Sonarr).
type searchView struct {
app *App
be backend
cfg config.Arr
root *tview.Flex
input *tview.InputField
@@ -33,7 +32,7 @@ type moviesView struct {
details *tview.TextView
// UI-goroutine state.
results []radarr.Movie
results []result
timer *time.Timer
cancel context.CancelFunc
gen int
@@ -46,16 +45,16 @@ type moviesView struct {
loaded bool
}
func newMoviesView(app *App, cfg config.Arr) *moviesView {
v := &moviesView{
func newSearchView(app *App, be backend, cfg config.Arr) *searchView {
v := &searchView{
app: app,
client: radarr.NewClient(cfg.URL, cfg.APIKey),
be: be,
cfg: cfg,
input: tview.NewInputField().SetLabel("Search: "),
list: tview.NewList().ShowSecondaryText(false),
details: tview.NewTextView().SetDynamicColors(true).SetWordWrap(true),
}
v.list.SetBorder(true).SetTitle(" Movies ")
v.list.SetBorder(true).SetTitle(" " + be.noun() + " ")
v.details.SetBorder(true).SetTitle(" Details ")
left := tview.NewFlex().SetDirection(tview.FlexRow).
@@ -105,7 +104,7 @@ func newMoviesView(app *App, cfg config.Arr) *moviesView {
}
// loadOptions checks the connection and fetches the data the add form needs.
func (v *moviesView) loadOptions() {
func (v *searchView) loadOptions() {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
@@ -113,12 +112,13 @@ func (v *moviesView) loadOptions() {
profiles []arr.QualityProfile
folders []arr.RootFolder
)
status, err := v.client.SystemStatus(ctx)
c := v.be.arr()
status, err := c.SystemStatus(ctx)
if err == nil {
profiles, err = v.client.QualityProfiles(ctx)
profiles, err = c.QualityProfiles(ctx)
}
if err == nil {
folders, err = v.client.RootFolders(ctx)
folders, err = c.RootFolders(ctx)
}
v.mu.Lock()
@@ -127,43 +127,42 @@ func (v *moviesView) loadOptions() {
v.app.post(func() {
if err != nil {
v.app.SetMessage("[red]Radarr: " + tview.Escape(err.Error()))
v.app.SetMessage(fmt.Sprintf("[red]%s: %s", v.be.name(), tview.Escape(err.Error())))
return
}
v.app.SetMessage(fmt.Sprintf("[green]Radarr %s connected", tview.Escape(status.Version)))
v.app.SetMessage(fmt.Sprintf("[green]%s %s connected", v.be.name(), tview.Escape(status.Version)))
})
}
func (v *moviesView) options() ([]arr.QualityProfile, []arr.RootFolder, error) {
func (v *searchView) options() ([]arr.QualityProfile, []arr.RootFolder, error) {
v.mu.Lock()
defer v.mu.Unlock()
if !v.loaded {
return nil, nil, errors.New("still connecting to Radarr")
return nil, nil, fmt.Errorf("still connecting to %s", v.be.name())
}
return v.profiles, v.folders, v.optsErr
}
// onSearchChanged debounces typing into a search.
func (v *moviesView) onSearchChanged(text string) {
func (v *searchView) onSearchChanged(text string) {
if v.timer != nil {
v.timer.Stop()
}
text = strings.TrimSpace(text)
gen := v.invalidate()
if len([]rune(text)) < minAutoSearch {
v.invalidate()
if text == "" {
v.setResults(nil)
}
return
}
gen := v.invalidate()
ctx, cancel := context.WithCancel(context.Background())
v.cancel = cancel
v.timer = time.AfterFunc(searchDebounce, func() { v.run(ctx, gen, text) })
}
// invalidate cancels any in-flight search and returns the new generation.
func (v *moviesView) invalidate() int {
func (v *searchView) invalidate() int {
if v.cancel != nil {
v.cancel()
v.cancel = nil
@@ -173,7 +172,7 @@ func (v *moviesView) invalidate() int {
}
// search runs a search immediately (UI goroutine).
func (v *moviesView) search(text string) {
func (v *searchView) search(text string) {
if v.timer != nil {
v.timer.Stop()
}
@@ -188,13 +187,13 @@ func (v *moviesView) search(text string) {
}
// run performs the request off the UI goroutine and applies fresh results.
func (v *moviesView) run(ctx context.Context, gen int, term string) {
func (v *searchView) run(ctx context.Context, gen int, term string) {
v.app.post(func() {
if gen == v.gen {
v.app.SetMessage("Searching…")
}
})
movies, err := v.client.Lookup(ctx, term)
results, err := v.be.search(ctx, term)
if errors.Is(err, context.Canceled) {
return
}
@@ -206,22 +205,22 @@ func (v *moviesView) run(ctx context.Context, gen int, term string) {
v.app.SetMessage("[red]Search failed: " + tview.Escape(err.Error()))
return
}
v.app.SetMessage(fmt.Sprintf("%d results for %q", len(movies), tview.Escape(term)))
v.setResults(movies)
v.app.SetMessage(fmt.Sprintf("%d results for %q", len(results), tview.Escape(term)))
v.setResults(results)
})
}
// setResults replaces the list contents (UI goroutine).
func (v *moviesView) setResults(movies []radarr.Movie) {
v.results = movies
func (v *searchView) setResults(results []result) {
v.results = results
v.render(0)
}
// render rebuilds the list, selecting index sel.
func (v *moviesView) render(sel int) {
func (v *searchView) render(sel int) {
v.list.Clear()
for _, m := range v.results {
v.list.AddItem(movieLabel(m), "", 0, nil)
for _, r := range v.results {
v.list.AddItem(r.label(), "", 0, nil)
}
if len(v.results) == 0 {
v.details.SetText("")
@@ -232,31 +231,31 @@ func (v *moviesView) render(sel int) {
v.showDetails(sel)
}
func (v *moviesView) showDetails(i int) {
func (v *searchView) showDetails(i int) {
if i < 0 || i >= len(v.results) {
v.details.SetText("")
return
}
v.details.SetText(movieDetails(v.results[i])).ScrollToBeginning()
v.details.SetText(v.results[i].details()).ScrollToBeginning()
}
// add opens the add dialog for the result at index i.
func (v *moviesView) add(i int) {
func (v *searchView) add(i int) {
if i < 0 || i >= len(v.results) {
return
}
m := v.results[i]
if m.InLibrary() {
v.app.SetMessage(fmt.Sprintf("[yellow]%s is already in Radarr", tview.Escape(m.Title)))
r := v.results[i]
if r.inLibrary() {
v.app.SetMessage(fmt.Sprintf("[yellow]%s is already in %s", tview.Escape(r.title()), v.be.name()))
return
}
profiles, folders, err := v.options()
switch {
case err != nil:
v.app.SetMessage("[red]Radarr: " + tview.Escape(err.Error()))
v.app.SetMessage(fmt.Sprintf("[red]%s: %s", v.be.name(), tview.Escape(err.Error())))
return
case len(profiles) == 0 || len(folders) == 0:
v.app.SetMessage("[red]Radarr has no quality profiles or root folders configured")
v.app.SetMessage(fmt.Sprintf("[red]%s has no quality profiles or root folders configured", v.be.name()))
return
}
@@ -268,62 +267,75 @@ func (v *moviesView) add(i int) {
for i, f := range folders {
folderPaths[i] = f.Path
}
modes := v.be.monitorModes()
modeNames := make([]string, len(modes))
for i, m := range modes {
modeNames[i] = m.label
}
const (
lProfile = "Quality profile"
lFolder = "Root folder"
lMonitor = "Monitored"
lEpisode = "Monitor"
lSearch = "Search now"
)
form := tview.NewForm().
AddDropDown(lProfile, profileNames, profileIndex(profiles, v.cfg.DefaultQualityProfile), nil).
AddDropDown(lFolder, folderPaths, folderIndex(folders, v.cfg.DefaultRootFolder), nil).
AddCheckbox(lMonitor, true, nil).
AddCheckbox(lSearch, true, nil)
AddDropDown(lFolder, folderPaths, folderIndex(folders, v.cfg.DefaultRootFolder), nil)
height := 13
if len(modes) > 0 {
form.AddDropDown(lEpisode, modeNames, 0, nil)
height += 2
}
form.AddCheckbox(lMonitor, true, nil).AddCheckbox(lSearch, true, nil)
form.AddButton("Add", func() {
opts := radarr.AddOptions{
QualityProfileID: profiles[selected(form, lProfile)].ID,
RootFolderPath: folders[selected(form, lFolder)].Path,
Monitored: form.GetFormItemByLabel(lMonitor).(*tview.Checkbox).IsChecked(),
SearchNow: form.GetFormItemByLabel(lSearch).(*tview.Checkbox).IsChecked(),
choice := addChoice{
profileID: profiles[selected(form, lProfile)].ID,
folder: folders[selected(form, lFolder)].Path,
monitored: form.GetFormItemByLabel(lMonitor).(*tview.Checkbox).IsChecked(),
searchNow: form.GetFormItemByLabel(lSearch).(*tview.Checkbox).IsChecked(),
}
if len(modes) > 0 {
choice.monitor = modes[selected(form, lEpisode)].value
}
v.app.closeModal()
v.doAdd(m, opts)
v.doAdd(r, choice)
})
form.AddButton("Cancel", v.app.closeModal)
form.SetCancelFunc(v.app.closeModal)
form.SetBorder(true).SetTitle(" Add " + tview.Escape(m.Title) + " ")
form.SetBorder(true).SetTitle(" Add " + tview.Escape(r.title()) + " ")
v.app.openModal(form, 60, 13)
v.app.openModal(form, 60, height)
}
func (v *moviesView) doAdd(m radarr.Movie, opts radarr.AddOptions) {
v.app.SetMessage("Adding " + tview.Escape(m.Title) + "…")
func (v *searchView) doAdd(r result, choice addChoice) {
v.app.SetMessage("Adding " + tview.Escape(r.title()) + "…")
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
created, err := v.client.AddMovie(ctx, m, opts)
id, err := v.be.add(ctx, r, choice)
v.app.post(func() {
if err != nil {
v.app.SetMessage("[red]Add failed: " + tview.Escape(err.Error()))
return
}
msg := "[green]Added " + tview.Escape(m.Title)
if opts.SearchNow {
msg := "[green]Added " + tview.Escape(r.title())
if choice.searchNow {
msg += " and started a search"
}
v.app.SetMessage(msg)
v.markAdded(created)
v.markAdded(r.key(), id)
})
}()
}
// markAdded flags the matching result as in the library, keeping the selection.
func (v *moviesView) markAdded(created radarr.Movie) {
for i := range v.results {
if v.results[i].TMDBID == created.TMDBID {
v.results[i].ID = created.ID
v.results[i].Monitored = created.Monitored
func (v *searchView) markAdded(key string, id int) {
for i, r := range v.results {
if r.key() == key {
v.results[i] = r.withID(id)
}
}
v.render(v.list.GetCurrentItem())