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 +}