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>
337 lines
8.5 KiB
Go
337 lines
8.5 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/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
|
|
}
|