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:
+27
-3
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user