The grey number of every entry ("1.2k watching", "on 18k lists") stood
right after its title, so they did not line up. The rows are now padded
to the list's width on every draw where it changed, putting the numbers
against the right edge; a title too long for that keeps the number right
after it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
481 lines
13 KiB
Go
481 lines
13 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"
|
|
"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
|
|
notesWidth int // list width the rows were laid out for
|
|
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(notesList{v.list, v}, 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()
|
|
_, _, width, _ := v.list.GetInnerRect()
|
|
v.notesWidth = width
|
|
for i := range v.results {
|
|
v.list.AddItem(v.itemText(i, width), "", 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)
|
|
}
|
|
|
|
// itemText is the row of result i in a list width columns wide: the label,
|
|
// and a Trakt list's number (if any) in grey against the right edge, so the
|
|
// numbers of all rows line up. A label too long for that gets the number
|
|
// right after it.
|
|
func (v *searchView) itemText(i, width int) string {
|
|
label := v.results[i].label()
|
|
if i >= len(v.notes) || v.notes[i] == "" {
|
|
return label
|
|
}
|
|
note := tview.Escape(v.notes[i])
|
|
gap := width - tview.TaggedStringWidth(label) - tview.TaggedStringWidth(note)
|
|
if gap < 2 {
|
|
gap = 2
|
|
}
|
|
return label + strings.Repeat(" ", gap) + "[gray]" + note + "[-]"
|
|
}
|
|
|
|
// notesList draws the results list, first laying the rows out again when the
|
|
// width changed (the numbers of a Trakt list sit against the right edge).
|
|
type notesList struct {
|
|
*tview.List
|
|
v *searchView
|
|
}
|
|
|
|
func (n notesList) Draw(screen tcell.Screen) {
|
|
v := n.v
|
|
if _, _, width, _ := v.list.GetInnerRect(); width != v.notesWidth && len(v.notes) > 0 {
|
|
v.notesWidth = width
|
|
for i := range v.results {
|
|
v.list.SetItemText(i, v.itemText(i, width), "")
|
|
}
|
|
}
|
|
n.List.Draw(screen)
|
|
}
|
|
|
|
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
|
|
}
|