Release listing and grab in the shared arr client, a full-screen release list (sort, hide rejected, rejection reasons, confirmation before grabbing rejected releases; Usenet only), and a season/episode picker for Sonarr. Overlays are now a stack so dialogs can open above the release list. Add a fake Newznab indexer to the dev setup. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
365 lines
9.1 KiB
Go
365 lines
9.1 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"
|
|
)
|
|
|
|
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:
|
|
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
|
|
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() {
|
|
if err != nil {
|
|
v.app.SetMessage(fmt.Sprintf("[red]%s: %s", v.be.name(), tview.Escape(err.Error())))
|
|
return
|
|
}
|
|
v.app.SetMessage(fmt.Sprintf("[green]%s %s connected", v.be.name(), tview.Escape(status.Version)))
|
|
})
|
|
}
|
|
|
|
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
|
|
}
|
|
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)
|
|
|
|
form.AddButton("Add", func() {
|
|
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(r, choice)
|
|
})
|
|
form.AddButton("Cancel", v.app.closeModal)
|
|
form.SetCancelFunc(v.app.closeModal)
|
|
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
|
|
}
|