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/sonarr" ) const ( searchDebounce = 300 * time.Millisecond minAutoSearch = 2 // characters before the debounced search kicks in ) // 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 list *tview.List details *tview.TextView // UI-goroutine state. results []result 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 newSearchView(app *App, be backend, cfg config.Arr) *searchView { v := &searchView{ app: app, 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(" " + be.noun() + " ") 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, tcell.KeyBacktab: 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.KeyTab || ev.Key() == tcell.KeyBacktab { v.app.app.SetFocus(v.input) return nil } 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 case 'i': v.interactive(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 *searchView) loadOptions() { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() var ( profiles []arr.QualityProfile folders []arr.RootFolder ) c := v.be.arr() status, err := c.SystemStatus(ctx) if err == nil { profiles, err = c.QualityProfiles(ctx) } if err == nil { folders, err = c.RootFolders(ctx) } v.mu.Lock() v.profiles, v.folders, v.optsErr, v.loaded = profiles, folders, err, true v.mu.Unlock() v.app.post(func() { v.app.SetService(v.be.name(), status.Version, err) if err != nil { v.app.SetMessage(fmt.Sprintf("[red]%s: %s", v.be.name(), tview.Escape(err.Error()))) } }) } func (v *searchView) options() ([]arr.QualityProfile, []arr.RootFolder, error) { v.mu.Lock() defer v.mu.Unlock() if !v.loaded { 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 *searchView) onSearchChanged(text string) { if v.timer != nil { v.timer.Stop() } text = strings.TrimSpace(text) gen := v.invalidate() if len([]rune(text)) < minAutoSearch { if text == "" { v.setResults(nil) } return } 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 *searchView) 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 *searchView) 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 *searchView) run(ctx context.Context, gen int, term string) { v.app.post(func() { if gen == v.gen { v.app.SetMessage("Searching…") } }) results, err := v.be.search(ctx, term) if errors.Is(err, context.Canceled) { return } v.app.post(func() { if gen != v.gen { return // superseded by a newer search } v.app.SetService(v.be.name(), "", err) if err != nil { v.app.SetMessage("[red]Search failed: " + tview.Escape(err.Error())) return } 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 *searchView) setResults(results []result) { v.results = results v.render(0) } // render rebuilds the list, selecting index sel. func (v *searchView) render(sel int) { v.list.Clear() for _, r := range v.results { v.list.AddItem(r.label(), "", 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 *searchView) showDetails(i int) { if i < 0 || i >= len(v.results) { v.details.SetText("") return } v.details.SetText(v.results[i].details()).ScrollToBeginning() } // interactive opens the release list for the result at index i. func (v *searchView) interactive(i int) { if i < 0 || i >= len(v.results) { return } r := v.results[i] if !r.inLibrary() { v.app.SetMessage(fmt.Sprintf("[yellow]Add %s to %s first (a), then search for releases", tview.Escape(r.title()), v.be.name())) return } v.be.interactive(v, r) } // add opens the add dialog for the result at index i. func (v *searchView) add(i int) { if i < 0 || i >= len(v.results) { return } 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(fmt.Sprintf("[red]%s: %s", v.be.name(), tview.Escape(err.Error()))) return case len(profiles) == 0 || len(folders) == 0: v.app.SetMessage(fmt.Sprintf("[red]%s has no quality profiles or root folders configured", v.be.name())) 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 } 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) height := 13 if len(modes) > 0 { form.AddDropDown(lEpisode, modeNames, 0, nil) height += 2 } form.AddCheckbox(lMonitor, true, nil).AddCheckbox(lSearch, true, nil) // Radarr and Sonarr skip unmonitored items in an automatic search, so // "Search now" only makes sense when something will be monitored. monitored := form.GetFormItemByLabel(lMonitor).(*tview.Checkbox) searchNow := form.GetFormItemByLabel(lSearch).(*tview.Checkbox) searchable := func() bool { if !monitored.IsChecked() { return false } return len(modes) == 0 || modes[selected(form, lEpisode)].value != sonarr.MonitorNone } syncSearch := bindSearchNow(searchNow, searchable, func() { v.app.Notify(levelWarn, "Search now needs something to be monitored") }) monitored.SetChangedFunc(func(bool) { syncSearch() }) if len(modes) > 0 { form.GetFormItemByLabel(lEpisode).(*tview.DropDown).SetSelectedFunc(func(string, int) { syncSearch() }) } form.AddButton("Add", func() { choice := addChoice{ profileID: profiles[selected(form, lProfile)].ID, folder: folders[selected(form, lFolder)].Path, monitored: monitored.IsChecked(), searchNow: searchNow.IsChecked() && searchable(), } if len(modes) > 0 { choice.monitor = modes[selected(form, lEpisode)].value } v.app.closeModal() v.doAdd(r, choice) }) form.AddButton("Cancel", v.app.closeModal) arrowNavigation(form, v.app.closeModal, nil) form.SetBorder(true).SetTitle(" Add " + tview.Escape(r.title()) + " ") v.app.openModal(form, 60, height) } 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() 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(r.title()) if choice.searchNow { msg += " and started a search" } v.app.SetMessage(msg) v.markAdded(r.key(), id) }) }() } // markAdded flags the matching result as in the library, keeping the selection. 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()) } // 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 }