From 2dd7c17737d4191e9cb1a359600ccab4d8a78217 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Tue, 20 Oct 2026 17:49:11 +0200 Subject: [PATCH] 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 --- SPECS.md | 5 +- internal/sonarr/sonarr.go | 163 +++++++++++++++++++++++++++ internal/sonarr/sonarr_test.go | 117 +++++++++++++++++++ internal/ui/app.go | 30 ++++- internal/ui/backend.go | 135 ++++++++++++++++++++++ internal/ui/format.go | 68 +++++++++++ internal/ui/format_test.go | 43 +++++++ internal/ui/{movies.go => search.go} | 138 ++++++++++++----------- 8 files changed, 631 insertions(+), 68 deletions(-) create mode 100644 internal/sonarr/sonarr.go create mode 100644 internal/sonarr/sonarr_test.go create mode 100644 internal/ui/backend.go rename internal/ui/{movies.go => search.go} (65%) diff --git a/SPECS.md b/SPECS.md index e7ebc87..899fa5d 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:** 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. diff --git a/internal/sonarr/sonarr.go b/internal/sonarr/sonarr.go new file mode 100644 index 0000000..72a49f3 --- /dev/null +++ b/internal/sonarr/sonarr.go @@ -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) +} diff --git a/internal/sonarr/sonarr_test.go b/internal/sonarr/sonarr_test.go new file mode 100644 index 0000000..b72fa80 --- /dev/null +++ b/internal/sonarr/sonarr_test.go @@ -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) + } +} diff --git a/internal/ui/app.go b/internal/ui/app.go index 2107d17..09ae22e 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -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) diff --git a/internal/ui/backend.go b/internal/ui/backend.go new file mode 100644 index 0000000..5551a73 --- /dev/null +++ b/internal/ui/backend.go @@ -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 +} diff --git a/internal/ui/format.go b/internal/ui/format.go index 30aba36..992ac71 100644 --- a/internal/ui/format.go +++ b/internal/ui/format.go @@ -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" +} diff --git a/internal/ui/format_test.go b/internal/ui/format_test.go index b4a03da..e5954f4 100644 --- a/internal/ui/format_test.go +++ b/internal/ui/format_test.go @@ -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()) + } +} diff --git a/internal/ui/movies.go b/internal/ui/search.go similarity index 65% rename from internal/ui/movies.go rename to internal/ui/search.go index a7f6cee..920775e 100644 --- a/internal/ui/movies.go +++ b/internal/ui/search.go @@ -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())