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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user