Files
arr-tui/internal/ui/search.go
T
2026-10-20 21:47:50 +02:00

387 lines
9.8 KiB
Go

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 *detailPane
// 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: newDetailPane(app),
}
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
}
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.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.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
}