package ui import ( "context" "errors" "fmt" "strings" "sync" "time" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" "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 ( searchDebounce = 300 * time.Millisecond 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 root *tview.Flex input *tview.InputField list *tview.List details *tview.TextView // UI-goroutine state. results []radarr.Movie timer *time.Timer cancel context.CancelFunc gen int // Add-form options, loaded in the background. mu sync.Mutex profiles []arr.QualityProfile folders []arr.RootFolder optsErr error loaded bool } func newMoviesView(app *App, cfg config.Arr) *moviesView { v := &moviesView{ app: app, client: radarr.NewClient(cfg.URL, cfg.APIKey), 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.details.SetBorder(true).SetTitle(" Details ") left := tview.NewFlex().SetDirection(tview.FlexRow). AddItem(v.input, 1, 0, true). AddItem(v.list, 0, 1, false) v.root = tview.NewFlex(). AddItem(left, 0, 1, true). AddItem(v.details, 0, 1, false) v.input.SetChangedFunc(v.onSearchChanged) v.input.SetDoneFunc(func(key tcell.Key) { switch key { case tcell.KeyEnter: v.search(strings.TrimSpace(v.input.GetText())) v.app.app.SetFocus(v.list) case tcell.KeyEscape, tcell.KeyTab: v.app.app.SetFocus(v.list) } }) v.input.SetInputCapture(func(ev *tcell.EventKey) *tcell.EventKey { if ev.Key() == tcell.KeyDown { v.app.app.SetFocus(v.list) return nil } return ev }) v.list.SetChangedFunc(func(i int, _, _ string, _ rune) { v.showDetails(i) }) v.list.SetSelectedFunc(func(i int, _, _ string, _ rune) { v.add(i) }) v.list.SetInputCapture(func(ev *tcell.EventKey) *tcell.EventKey { if ev.Key() != tcell.KeyRune { return ev } switch ev.Rune() { case '/': v.app.app.SetFocus(v.input) return nil case 'a': v.add(v.list.GetCurrentItem()) return nil } return ev }) go v.loadOptions() return v } // loadOptions checks the connection and fetches the data the add form needs. func (v *moviesView) loadOptions() { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() var ( profiles []arr.QualityProfile folders []arr.RootFolder ) status, err := v.client.SystemStatus(ctx) if err == nil { profiles, err = v.client.QualityProfiles(ctx) } if err == nil { folders, err = v.client.RootFolders(ctx) } v.mu.Lock() v.profiles, v.folders, v.optsErr, v.loaded = profiles, folders, err, true v.mu.Unlock() v.app.post(func() { if err != nil { v.app.SetMessage("[red]Radarr: " + tview.Escape(err.Error())) return } v.app.SetMessage(fmt.Sprintf("[green]Radarr %s connected", tview.Escape(status.Version))) }) } func (v *moviesView) 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 v.profiles, v.folders, v.optsErr } // onSearchChanged debounces typing into a search. func (v *moviesView) onSearchChanged(text string) { if v.timer != nil { v.timer.Stop() } text = strings.TrimSpace(text) 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 { if v.cancel != nil { v.cancel() v.cancel = nil } v.gen++ return v.gen } // search runs a search immediately (UI goroutine). func (v *moviesView) search(text string) { if v.timer != nil { v.timer.Stop() } gen := v.invalidate() if text == "" { v.setResults(nil) return } ctx, cancel := context.WithCancel(context.Background()) v.cancel = cancel go v.run(ctx, gen, text) } // run performs the request off the UI goroutine and applies fresh results. func (v *moviesView) 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) if errors.Is(err, context.Canceled) { return } v.app.post(func() { if gen != v.gen { return // superseded by a newer search } if err != nil { 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) }) } // setResults replaces the list contents (UI goroutine). func (v *moviesView) setResults(movies []radarr.Movie) { v.results = movies v.render(0) } // render rebuilds the list, selecting index sel. func (v *moviesView) render(sel int) { v.list.Clear() for _, m := range v.results { v.list.AddItem(movieLabel(m), "", 0, nil) } if len(v.results) == 0 { v.details.SetText("") return } sel = min(max(sel, 0), len(v.results)-1) v.list.SetCurrentItem(sel) v.showDetails(sel) } func (v *moviesView) showDetails(i int) { if i < 0 || i >= len(v.results) { v.details.SetText("") return } v.details.SetText(movieDetails(v.results[i])).ScrollToBeginning() } // add opens the add dialog for the result at index i. func (v *moviesView) 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))) return } profiles, folders, err := v.options() switch { case err != nil: v.app.SetMessage("[red]Radarr: " + 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") return } profileNames := make([]string, len(profiles)) for i, p := range profiles { profileNames[i] = p.Name } folderPaths := make([]string, len(folders)) for i, f := range folders { folderPaths[i] = f.Path } const ( lProfile = "Quality profile" lFolder = "Root folder" lMonitor = "Monitored" 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) 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(), } v.app.closeModal() v.doAdd(m, opts) }) form.AddButton("Cancel", v.app.closeModal) form.SetCancelFunc(v.app.closeModal) form.SetBorder(true).SetTitle(" Add " + tview.Escape(m.Title) + " ") v.app.openModal(form, 60, 13) } func (v *moviesView) doAdd(m radarr.Movie, opts radarr.AddOptions) { v.app.SetMessage("Adding " + tview.Escape(m.Title) + "…") go func() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() created, err := v.client.AddMovie(ctx, m, opts) 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 += " and started a search" } v.app.SetMessage(msg) v.markAdded(created) }) }() } // 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 } } v.render(v.list.GetCurrentItem()) } // selected returns the chosen index of the dropdown labelled label. func selected(form *tview.Form, label string) int { i, _ := form.GetFormItemByLabel(label).(*tview.DropDown).GetCurrentOption() return i }