Add Sonarr client and series search/add flow (M3)

Sonarr client with lookup, add series (monitor modes) and search.
Refactor the movie view into a generic search view driven by a backend
interface so Movies and Series share one implementation.
Add F1-F4 / Alt+1-4 view switching that works while typing.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 17:49:11 +02:00
co-authored by Claude Sonnet 5.5
parent 51fb0556b0
commit 2dd7c17737
8 changed files with 631 additions and 68 deletions
+348
View File
@@ -0,0 +1,348 @@
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
}
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()
}
// 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
}