From 51fb0556b074cb170476db6c8e637ebf91787d32 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Tue, 20 Oct 2026 17:42:26 +0200 Subject: [PATCH] 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 --- SPECS.md | 4 +- cmd/arr-tui/main.go | 4 +- internal/ui/app.go | 79 ++++++++- internal/ui/format.go | 83 +++++++++ internal/ui/format_test.go | 45 +++++ internal/ui/movies.go | 336 +++++++++++++++++++++++++++++++++++++ 6 files changed, 538 insertions(+), 13 deletions(-) create mode 100644 internal/ui/format.go create mode 100644 internal/ui/format_test.go create mode 100644 internal/ui/movies.go diff --git a/SPECS.md b/SPECS.md index 722baed..e7ebc87 100644 --- a/SPECS.md +++ b/SPECS.md @@ -6,7 +6,7 @@ A terminal UI to search for movies and series, add them to Radarr / Sonarr, and - **TUI library:** [tview](https://github.com/rivo/tview) (on top of tcell) - **License:** MIT - **Module:** `git.stinnesbeck.com/nils/arr-tui` -- **Status:** M1 done +- **Status:** M2 done ## 1. Goals @@ -182,7 +182,7 @@ The file should be created with mode `0600`; warn if it is more permissive. API - [x] **M0** — Project skeleton: `go mod init`, config loading, empty tview app with tabs and status bar. - [x] **M1** — Radarr client: system status, lookup, quality profiles, root folders, add movie. -- [ ] **M2** — Movie search + add flow in the UI. +- [x] **M2** — Movie search + add flow in the UI. - [ ] **M3** — Sonarr client and series search + add flow. - [ ] **M4** — SABnzbd client and Downloads view (queue + history) with live updates and pause/resume/delete. - [ ] **M5** — Radarr/Sonarr queues merged into the Downloads view. diff --git a/cmd/arr-tui/main.go b/cmd/arr-tui/main.go index b5001e4..41c6c2e 100644 --- a/cmd/arr-tui/main.go +++ b/cmd/arr-tui/main.go @@ -29,7 +29,7 @@ func run() error { *path = p } - _, warnings, err := config.Load(*path) + cfg, warnings, err := config.Load(*path) if err != nil { return err } @@ -37,5 +37,5 @@ func run() error { fmt.Fprintln(os.Stderr, "warning:", w) } - return ui.New().Run() + return ui.New(cfg).Run() } diff --git a/internal/ui/app.go b/internal/ui/app.go index 9e4fbf6..2107d17 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -6,8 +6,12 @@ import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" + + "git.stinnesbeck.com/nils/arr-tui/internal/config" ) +const modalPage = "modal" + type view struct { key rune name string @@ -22,10 +26,14 @@ type App struct { status *tview.TextView views []view active int + + message string + modalOpen bool + modalFocus tview.Primitive } // New builds the application shell: a tab bar, one page per view and a status bar. -func New() *App { +func New(cfg *config.Config) *App { a := &App{ app: tview.NewApplication(), tabs: tview.NewTextView().SetDynamicColors(true), @@ -34,7 +42,7 @@ func New() *App { } a.views = []view{ - {'1', "Movies", placeholder("Movie search (M2)")}, + {'1', "Movies", newMoviesView(a, cfg.Radarr).root}, {'2', "Series", placeholder("Series search (M3)")}, {'3', "Downloads", placeholder("Downloads (M4/M5)")}, {'4', "Library", placeholder("Library (v0.2)")}, @@ -50,15 +58,21 @@ func New() *App { a.app.SetRoot(layout, true).EnableMouse(false) a.app.SetInputCapture(a.handleKey) - a.SetStatus("[gray]q quit · Tab / 1-4 switch view") a.switchTo(0) + a.renderStatus() return a } -// SetStatus replaces the status bar text. Safe to call only from the UI goroutine; -// use QueueUpdateDraw from other goroutines. -func (a *App) SetStatus(text string) { - a.status.SetText(text) +// SetMessage shows a message in the status bar. UI goroutine only; use +// post from other goroutines. +func (a *App) SetMessage(text string) { + a.message = text + a.renderStatus() +} + +// post runs f on the UI goroutine and redraws. Safe to call from any goroutine. +func (a *App) post(f func()) { + a.app.QueueUpdateDraw(f) } // Run starts the event loop and blocks until the application quits. @@ -66,11 +80,58 @@ func (a *App) Run() error { return a.app.Run() } +func (a *App) renderStatus() { + hints := "[gray]q quit · Tab/1-4 views" + if a.message == "" { + a.status.SetText(hints) + return + } + a.status.SetText(a.message + " [gray]│ " + hints) +} + +// openModal shows p centered over the current view and remembers the focus. +func (a *App) openModal(p tview.Primitive, width, height int) { + a.modalFocus = a.app.GetFocus() + a.modalOpen = true + box := tview.NewFlex(). + AddItem(nil, 0, 1, false). + AddItem(tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(nil, 0, 1, false). + AddItem(p, height, 0, true). + AddItem(nil, 0, 1, false), width, 0, true). + AddItem(nil, 0, 1, false) + a.pages.AddPage(modalPage, box, true, true) + a.app.SetFocus(p) +} + +func (a *App) closeModal() { + a.pages.RemovePage(modalPage) + a.modalOpen = false + if a.modalFocus != nil { + a.app.SetFocus(a.modalFocus) + a.modalFocus = nil + } +} + +// typing reports whether keystrokes belong to a text field or dialog and must +// not trigger global shortcuts. +func (a *App) typing() bool { + if a.modalOpen { + return true + } + _, isInput := a.app.GetFocus().(*tview.InputField) + return isInput +} + func (a *App) handleKey(ev *tcell.EventKey) *tcell.EventKey { - switch ev.Key() { - case tcell.KeyCtrlC: + if ev.Key() == tcell.KeyCtrlC { a.app.Stop() return nil + } + if a.typing() { + return ev + } + switch ev.Key() { case tcell.KeyTab: a.switchTo((a.active + 1) % len(a.views)) return nil diff --git a/internal/ui/format.go b/internal/ui/format.go new file mode 100644 index 0000000..30aba36 --- /dev/null +++ b/internal/ui/format.go @@ -0,0 +1,83 @@ +package ui + +import ( + "fmt" + "strings" + + "github.com/rivo/tview" + + "git.stinnesbeck.com/nils/arr-tui/internal/arr" + "git.stinnesbeck.com/nils/arr-tui/internal/radarr" +) + +// movieLabel is the one-line list entry for a movie. +func movieLabel(m radarr.Movie) string { + label := tview.Escape(m.Title) + if m.Year > 0 { + label += fmt.Sprintf(" (%d)", m.Year) + } + if m.InLibrary() { + label += " [green]✓[-]" + } + return label +} + +// movieDetails renders the detail pane text for a movie. +func movieDetails(m radarr.Movie) string { + var b strings.Builder + title := tview.Escape(m.Title) + if m.Year > 0 { + title += fmt.Sprintf(" (%d)", m.Year) + } + fmt.Fprintf(&b, "[::b]%s[::-]\n", title) + + var facts []string + if r := m.Rating(); r > 0 { + facts = append(facts, fmt.Sprintf("★ %.1f", r)) + } + if m.Runtime > 0 { + facts = append(facts, fmt.Sprintf("%dh%02dm", m.Runtime/60, m.Runtime%60)) + } + if len(m.Genres) > 0 { + facts = append(facts, tview.Escape(strings.Join(m.Genres, ", "))) + } + if len(facts) > 0 { + b.WriteString(strings.Join(facts, " · ") + "\n") + } + if m.Studio != "" { + fmt.Fprintf(&b, "Studio: %s\n", tview.Escape(m.Studio)) + } + if m.Status != "" { + fmt.Fprintf(&b, "Status: %s\n", tview.Escape(m.Status)) + } + switch { + case m.HasFile: + b.WriteString("[green]In library, downloaded[-]\n") + case m.InLibrary(): + b.WriteString("[yellow]In library, not downloaded[-]\n") + } + if m.Overview != "" { + b.WriteString("\n" + tview.Escape(m.Overview) + "\n") + } + return b.String() +} + +// profileIndex returns the index of the profile named name, or 0. +func profileIndex(profiles []arr.QualityProfile, name string) int { + for i, p := range profiles { + if strings.EqualFold(p.Name, name) { + return i + } + } + return 0 +} + +// folderIndex returns the index of the root folder at path, or 0. +func folderIndex(folders []arr.RootFolder, path string) int { + for i, f := range folders { + if strings.TrimRight(f.Path, "/") == strings.TrimRight(path, "/") { + return i + } + } + return 0 +} diff --git a/internal/ui/format_test.go b/internal/ui/format_test.go new file mode 100644 index 0000000..b4a03da --- /dev/null +++ b/internal/ui/format_test.go @@ -0,0 +1,45 @@ +package ui + +import ( + "strings" + "testing" + + "git.stinnesbeck.com/nils/arr-tui/internal/arr" + "git.stinnesbeck.com/nils/arr-tui/internal/radarr" +) + +func TestMovieLabel(t *testing.T) { + if got := movieLabel(radarr.Movie{Title: "Alien", Year: 1979}); got != "Alien (1979)" { + t.Errorf("got %q", got) + } + if got := movieLabel(radarr.Movie{ID: 3, Title: "Alien", Year: 1979}); !strings.Contains(got, "✓") { + t.Errorf("in-library marker missing: %q", got) + } + // tview color tags in titles must be escaped + if got := movieLabel(radarr.Movie{Title: "[red]x"}); strings.Contains(got, "[red]x") { + t.Errorf("title not escaped: %q", got) + } +} + +func TestMovieDetails(t *testing.T) { + got := movieDetails(radarr.Movie{ + ID: 1, Title: "Alien", Year: 1979, Runtime: 117, Genres: []string{"Horror", "Sci-Fi"}, + Overview: "In space...", Ratings: radarr.Ratings{IMDB: &radarr.Rating{Value: 8.5}}, + }) + for _, want := range []string{"Alien (1979)", "★ 8.5", "1h57m", "Horror, Sci-Fi", "In library, not downloaded", "In space..."} { + if !strings.Contains(got, want) { + t.Errorf("details missing %q:\n%s", want, got) + } + } +} + +func TestDefaultIndexes(t *testing.T) { + profiles := []arr.QualityProfile{{Name: "Any"}, {Name: "HD-1080p"}} + if profileIndex(profiles, "hd-1080p") != 1 || profileIndex(profiles, "nope") != 0 { + t.Error("profileIndex") + } + folders := []arr.RootFolder{{Path: "/a"}, {Path: "/movies/"}} + if folderIndex(folders, "/movies") != 1 || folderIndex(folders, "") != 0 { + t.Error("folderIndex") + } +} diff --git a/internal/ui/movies.go b/internal/ui/movies.go new file mode 100644 index 0000000..a7f6cee --- /dev/null +++ b/internal/ui/movies.go @@ -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 +}