Files
nilsandClaude Opus 5.5 8775c27aab Movies and Series: Enter adds, a no longer does
The status bar only ever named Enter, while the hint for searching
releases of a title not in the library said (a). Enter is now the one
key for adding, and the hint says Enter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:23:34 +02:00

478 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 '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 (Enter), 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
}