Add movie search and add flow (M2)
Movies view with debounced Radarr lookup, detail pane, and an add dialog (quality profile, root folder, monitored, search now). Global shortcuts are suspended while typing or in a dialog. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
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/radarr"
|
||||
)
|
||||
|
||||
const (
|
||||
searchDebounce = 300 * time.Millisecond
|
||||
minAutoSearch = 2 // characters before the debounced search kicks in
|
||||
)
|
||||
|
||||
// moviesView is the movie search / add screen backed by Radarr.
|
||||
type moviesView struct {
|
||||
app *App
|
||||
client *radarr.Client
|
||||
cfg config.Arr
|
||||
|
||||
root *tview.Flex
|
||||
input *tview.InputField
|
||||
list *tview.List
|
||||
details *tview.TextView
|
||||
|
||||
// UI-goroutine state.
|
||||
results []radarr.Movie
|
||||
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 newMoviesView(app *App, cfg config.Arr) *moviesView {
|
||||
v := &moviesView{
|
||||
app: app,
|
||||
client: radarr.NewClient(cfg.URL, cfg.APIKey),
|
||||
cfg: cfg,
|
||||
input: tview.NewInputField().SetLabel("Search: "),
|
||||
list: tview.NewList().ShowSecondaryText(false),
|
||||
details: tview.NewTextView().SetDynamicColors(true).SetWordWrap(true),
|
||||
}
|
||||
v.list.SetBorder(true).SetTitle(" Movies ")
|
||||
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 *moviesView) loadOptions() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var (
|
||||
profiles []arr.QualityProfile
|
||||
folders []arr.RootFolder
|
||||
)
|
||||
status, err := v.client.SystemStatus(ctx)
|
||||
if err == nil {
|
||||
profiles, err = v.client.QualityProfiles(ctx)
|
||||
}
|
||||
if err == nil {
|
||||
folders, err = v.client.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("[red]Radarr: " + tview.Escape(err.Error()))
|
||||
return
|
||||
}
|
||||
v.app.SetMessage(fmt.Sprintf("[green]Radarr %s connected", tview.Escape(status.Version)))
|
||||
})
|
||||
}
|
||||
|
||||
func (v *moviesView) options() ([]arr.QualityProfile, []arr.RootFolder, error) {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
if !v.loaded {
|
||||
return nil, nil, errors.New("still connecting to Radarr")
|
||||
}
|
||||
return v.profiles, v.folders, v.optsErr
|
||||
}
|
||||
|
||||
// onSearchChanged debounces typing into a search.
|
||||
func (v *moviesView) onSearchChanged(text string) {
|
||||
if v.timer != nil {
|
||||
v.timer.Stop()
|
||||
}
|
||||
text = strings.TrimSpace(text)
|
||||
if len([]rune(text)) < minAutoSearch {
|
||||
v.invalidate()
|
||||
if text == "" {
|
||||
v.setResults(nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
gen := v.invalidate()
|
||||
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 *moviesView) 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 *moviesView) 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 *moviesView) run(ctx context.Context, gen int, term string) {
|
||||
v.app.post(func() {
|
||||
if gen == v.gen {
|
||||
v.app.SetMessage("Searching…")
|
||||
}
|
||||
})
|
||||
movies, err := v.client.Lookup(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(movies), tview.Escape(term)))
|
||||
v.setResults(movies)
|
||||
})
|
||||
}
|
||||
|
||||
// setResults replaces the list contents (UI goroutine).
|
||||
func (v *moviesView) setResults(movies []radarr.Movie) {
|
||||
v.results = movies
|
||||
v.render(0)
|
||||
}
|
||||
|
||||
// render rebuilds the list, selecting index sel.
|
||||
func (v *moviesView) render(sel int) {
|
||||
v.list.Clear()
|
||||
for _, m := range v.results {
|
||||
v.list.AddItem(movieLabel(m), "", 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 *moviesView) showDetails(i int) {
|
||||
if i < 0 || i >= len(v.results) {
|
||||
v.details.SetText("")
|
||||
return
|
||||
}
|
||||
v.details.SetText(movieDetails(v.results[i])).ScrollToBeginning()
|
||||
}
|
||||
|
||||
// add opens the add dialog for the result at index i.
|
||||
func (v *moviesView) add(i int) {
|
||||
if i < 0 || i >= len(v.results) {
|
||||
return
|
||||
}
|
||||
m := v.results[i]
|
||||
if m.InLibrary() {
|
||||
v.app.SetMessage(fmt.Sprintf("[yellow]%s is already in Radarr", tview.Escape(m.Title)))
|
||||
return
|
||||
}
|
||||
profiles, folders, err := v.options()
|
||||
switch {
|
||||
case err != nil:
|
||||
v.app.SetMessage("[red]Radarr: " + tview.Escape(err.Error()))
|
||||
return
|
||||
case len(profiles) == 0 || len(folders) == 0:
|
||||
v.app.SetMessage("[red]Radarr has no quality profiles or root folders configured")
|
||||
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
|
||||
}
|
||||
|
||||
const (
|
||||
lProfile = "Quality profile"
|
||||
lFolder = "Root folder"
|
||||
lMonitor = "Monitored"
|
||||
lSearch = "Search now"
|
||||
)
|
||||
form := tview.NewForm().
|
||||
AddDropDown(lProfile, profileNames, profileIndex(profiles, v.cfg.DefaultQualityProfile), nil).
|
||||
AddDropDown(lFolder, folderPaths, folderIndex(folders, v.cfg.DefaultRootFolder), nil).
|
||||
AddCheckbox(lMonitor, true, nil).
|
||||
AddCheckbox(lSearch, true, nil)
|
||||
form.AddButton("Add", func() {
|
||||
opts := radarr.AddOptions{
|
||||
QualityProfileID: profiles[selected(form, lProfile)].ID,
|
||||
RootFolderPath: folders[selected(form, lFolder)].Path,
|
||||
Monitored: form.GetFormItemByLabel(lMonitor).(*tview.Checkbox).IsChecked(),
|
||||
SearchNow: form.GetFormItemByLabel(lSearch).(*tview.Checkbox).IsChecked(),
|
||||
}
|
||||
v.app.closeModal()
|
||||
v.doAdd(m, opts)
|
||||
})
|
||||
form.AddButton("Cancel", v.app.closeModal)
|
||||
form.SetCancelFunc(v.app.closeModal)
|
||||
form.SetBorder(true).SetTitle(" Add " + tview.Escape(m.Title) + " ")
|
||||
|
||||
v.app.openModal(form, 60, 13)
|
||||
}
|
||||
|
||||
func (v *moviesView) doAdd(m radarr.Movie, opts radarr.AddOptions) {
|
||||
v.app.SetMessage("Adding " + tview.Escape(m.Title) + "…")
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
created, err := v.client.AddMovie(ctx, m, opts)
|
||||
v.app.post(func() {
|
||||
if err != nil {
|
||||
v.app.SetMessage("[red]Add failed: " + tview.Escape(err.Error()))
|
||||
return
|
||||
}
|
||||
msg := "[green]Added " + tview.Escape(m.Title)
|
||||
if opts.SearchNow {
|
||||
msg += " and started a search"
|
||||
}
|
||||
v.app.SetMessage(msg)
|
||||
v.markAdded(created)
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
// markAdded flags the matching result as in the library, keeping the selection.
|
||||
func (v *moviesView) markAdded(created radarr.Movie) {
|
||||
for i := range v.results {
|
||||
if v.results[i].TMDBID == created.TMDBID {
|
||||
v.results[i].ID = created.ID
|
||||
v.results[i].Monitored = created.Monitored
|
||||
}
|
||||
}
|
||||
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