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" "git.stinnesbeck.com/nils/arr-tui/internal/trakt" ) 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 root *tview.Flex input *tview.InputField list *tview.List details *detailPane // UI-goroutine state. results []result notes []string // per result, a Trakt list's number ("1.2k watching"); nil for searches placeholder string // shown when there are no results timer *time.Timer cancel context.CancelFunc gen int // Trakt lists, shown while the search box is empty (see discover.go). lists []trakt.List listIdx int browsing bool // the results are a Trakt list discover map[int]*discovered loadingList map[int]bool // 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) *searchView { v := &searchView{ app: app, be: be, input: tview.NewInputField().SetLabel("Search: "), list: tview.NewList().ShowSecondaryText(false), details: newDetailPane(app), lists: be.lists(), discover: map[int]*discovered{}, loadingList: map[int]bool{}, } v.list.SetBorder(true).SetTitle(" " + be.noun() + " ") 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 } // While the box is empty a Trakt list is shown, and [ ] switch it (no // search starts with a bracket). if ev.Key() == tcell.KeyRune && (ev.Rune() == '[' || ev.Rune() == ']') && v.browsing && v.input.GetText() == "" { v.switchList(map[rune]int{'[': -1, ']': 1}[ev.Rune()]) 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 '[', ']': if v.browsing { v.switchList(map[rune]int{'[': -1, ']': 1}[ev.Rune()]) return nil } case 'r': if v.browsing { v.reloadList() return nil } case 'a': v.add(v.list.GetCurrentItem()) return nil case 'i': v.interactive(v.list.GetCurrentItem()) return nil } return ev }) app.reconnect = append(app.reconnect, v.reloadOptions) go v.loadOptions() return v } // defaults returns the add-form defaults of the service, as currently configured. func (v *searchView) defaults() config.Arr { if v.be.name() == "Radarr" { return v.app.cfg.Radarr } return v.app.cfg.Sonarr } // reloadOptions fetches the profiles and folders again, after the service changed. func (v *searchView) reloadOptions() { v.mu.Lock() v.loaded = false v.mu.Unlock() go v.loadOptions() } // 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.showList() } 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.showList() 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 with search results (UI goroutine). func (v *searchView) setResults(results []result) { v.results, v.notes, v.placeholder, v.browsing = results, nil, "", false v.list.SetTitle(" " + v.be.noun() + " ") v.render(0) } // activate shows a Trakt list when the view opens with an empty search. func (v *searchView) activate() { if !v.browsing && len(v.results) == 0 && strings.TrimSpace(v.input.GetText()) == "" { v.showList() } } // render rebuilds the list, selecting index sel. func (v *searchView) render(sel int) { v.list.Clear() for i, r := range v.results { label := r.label() if i < len(v.notes) && v.notes[i] != "" { label += " [gray]" + tview.Escape(v.notes[i]) + "[-]" } v.list.AddItem(label, "", 0, nil) } if len(v.results) == 0 { if v.placeholder != "" { v.list.AddItem("[gray]"+v.placeholder+"[-]", "", 0, nil) } v.details.Clear() 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.Clear() return } v.details.Show(v.results[i].details(), v.results[i].cover()) } // 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.defaults().DefaultQualityProfile), nil). AddDropDown(lFolder, folderPaths, folderIndex(folders, v.defaults().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 }