diff --git a/README.md b/README.md index 35170a6..898b62b 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ A terminal UI to search for movies and series with [Radarr](https://radarr.video per-episode monitoring, automatic search and release search. - **Downloads**: SABnzbd queue and history plus the Radarr/Sonarr queues. Pause/resume, delete, retry failed downloads, remove from the arr queue and blocklist. +- **Log (F5)**: actions report through popups that fade away; the log keeps the history. - Connection indicators for all three services in the top right; `?` shows every key. Requires Radarr/Sonarr v3+ API (Radarr 5/6, Sonarr 4) and SABnzbd 4+. Only Usenet releases are diff --git a/SPECS.md b/SPECS.md index db8dfca..a898f39 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:** M8 done +- **Status:** M9 done ## 1. Goals @@ -62,7 +62,8 @@ Notes: - SABnzbd queue (name, size, progress, ETA, speed) and history (completed / failed, with retry). - Radarr and Sonarr queues (status, warnings, import errors), so stuck imports are visible. - Global speed / pause state in the status bar. Pause/resume and delete a SABnzbd job. -- **Status bar / help:** key hints, connection state of each service. +- **Status bar / help:** the bottom bar lists the keys that work in the current view or panel; the top right shows the connection state of each service; `?` opens the full key reference. +- **Notifications and log (F5):** feedback about actions (added, deleted, grabbed, failures) appears as popups in the bottom right that fade away (errors stay longer). Everything is also kept in the Log view; only connection changes of the background polling are logged, not every failed poll. ### 4.2 v0.2 @@ -195,7 +196,8 @@ The file should be created with mode `0600`; warn if it is more permissive. API - [x] **M6** — Interactive release search (movies, then series seasons/episodes) and grab. - [x] **M7** — Polish: help screen (`?`/F9), per-service connection indicators, README, Makefile release builds, `--version`. - [x] **M8** — Library view (movies/series, filters, text filter, monitor toggle, automatic search) and series drill-down (seasons/episodes, monitoring, search, release search). -- [ ] **M9+** — ideas: calendar, notifications, delete from library, multiple instances. +- [x] **M9** — Popup notifications, Log view (F5), context-sensitive key hints in the status bar. +- [ ] **M10+** — ideas: calendar, notifications, delete from library, multiple instances. ## 10. Open Questions diff --git a/internal/ui/app.go b/internal/ui/app.go index 8c4e6f8..10c909a 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -21,6 +21,8 @@ type view struct { body tview.Primitive // Optional hooks, called when the view becomes visible / hidden. onShow, onHide func() + // hints lists the keys that work in this view (shown in the status bar). + hints func() string } // App is the top-level arr-tui application. @@ -32,7 +34,8 @@ type App struct { views []view active int - message string + toasts []toast + logs *logView overlays []overlay // dialogs and full-screen views stacked above the pages services *tview.TextView @@ -50,9 +53,11 @@ type serviceState struct { var serviceNames = []string{"Radarr", "Sonarr", "SABnzbd"} // overlay is a page stacked above the main views; focus is restored on close. +// hints replaces the view's key hints in the status bar while it is open. type overlay struct { page string focus tview.Primitive + hints string } // New builds the application shell: a tab bar, one page per view and a status bar. @@ -69,6 +74,7 @@ func New(cfg *config.Config) *App { radarrClient := radarr.NewClient(cfg.Radarr.URL, cfg.Radarr.APIKey) sonarrClient := sonarr.NewClient(cfg.Sonarr.URL, cfg.Sonarr.APIKey) + a.logs = newLogView(a) library := newLibraryView(a, radarrClient, sonarrClient) downloads := newDownloadsView(a, sabnzbd.NewClient(cfg.SABnzbd.URL, cfg.SABnzbd.APIKey), []arrSource{ @@ -77,10 +83,11 @@ func New(cfg *config.Config) *App { }, time.Duration(cfg.UI.RefreshSeconds)*time.Second) a.views = []view{ - {key: '1', name: "Movies", body: newSearchView(a, radarrBackend{radarrClient}, cfg.Radarr).root}, - {key: '2', name: "Series", body: newSearchView(a, sonarrBackend{sonarrClient}, cfg.Sonarr).root}, - {key: '3', name: "Downloads", body: downloads.root, onShow: downloads.activate, onHide: downloads.deactivate}, - {key: '4', name: "Library", body: library.root, onShow: library.activate}, + {key: '1', name: "Movies", body: newSearchView(a, radarrBackend{radarrClient}, cfg.Radarr).root, hints: searchHints}, + {key: '2', name: "Series", body: newSearchView(a, sonarrBackend{sonarrClient}, cfg.Sonarr).root, hints: searchHints}, + {key: '3', name: "Downloads", body: downloads.root, onShow: downloads.activate, onHide: downloads.deactivate, hints: downloads.hints}, + {key: '4', name: "Library", body: library.root, onShow: library.activate, hints: libraryHints}, + {key: '5', name: "Log", body: a.logs.root, onShow: a.logs.onShow, hints: logHints}, } for i, v := range a.views { a.pages.AddPage(v.name, v.body, true, i == 0) @@ -89,14 +96,13 @@ func New(cfg *config.Config) *App { layout := tview.NewFlex().SetDirection(tview.FlexRow). AddItem(tview.NewFlex(). AddItem(a.tabs, 0, 1, false). - AddItem(a.services, 34, 0, false), 1, 0, false). + AddItem(a.services, 25, 0, false), 1, 0, false). AddItem(a.pages, 0, 1, true). AddItem(a.status, 1, 0, false) - a.app.SetRoot(layout, true).EnableMouse(false) + a.app.SetRoot(&toastRoot{Flex: layout, app: a}, true).EnableMouse(false) a.app.SetInputCapture(a.handleKey) a.switchTo(0) - a.renderStatus() a.renderServices() return a } @@ -109,20 +115,28 @@ func (a *App) SetService(name, version string, err error) { version = prev.version } a.state[name] = serviceState{known: true, version: version, err: err} + // Log connection changes only, so background polling does not flood the log. + switch { + case err != nil && (!prev.known || prev.err == nil): + a.logs.add(levelError, name+" unreachable: "+err.Error()) + case err == nil && (!prev.known || prev.err != nil): + a.logs.add(levelSuccess, strings.TrimSpace(name+" "+version)+" connected") + } a.renderServices() } func (a *App) renderServices() { var s string for _, n := range serviceNames { + short := strings.TrimSuffix(n, "nzbd") // SABnzbd -> SAB, keeps the bar narrow st := a.state[n] switch { case !st.known: - s += "[gray]○ " + n + "[-] " + s += "[gray]○ " + short + "[-] " case st.err != nil: - s += "[red]● " + n + "[-] " + s += "[red]● " + short + "[-] " default: - s += "[green]●[-] " + n + " " + s += "[green]●[-] " + short + " " } } a.services.SetText(s) @@ -147,13 +161,6 @@ func (a *App) serviceSummary() string { return b.String() } -// 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) @@ -165,21 +172,22 @@ func (a *App) Run() error { } func (a *App) renderStatus() { - hints := "[gray]?/F9 help · q quit · F1-F4 views · Tab panels · / search" - if len(a.overlays) > 0 { - hints = "[gray]Esc back · Ctrl+C quit" + var hints string + if n := len(a.overlays); n > 0 { + hints = a.overlays[n-1].hints + " · Ctrl+C quit" + } else { + if h := a.views[a.active].hints; h != nil { + hints = h() + " · " + } + hints += "? help · F1-F5 · q quit" } - if a.message == "" { - a.status.SetText(hints) - return - } - a.status.SetText(a.message + " [gray]│ " + hints) + a.status.SetText("[gray]" + hints) } // pushOverlay stacks p above everything and focuses focus. -func (a *App) pushOverlay(p, focus tview.Primitive) { +func (a *App) pushOverlay(p, focus tview.Primitive, hints string) { name := fmt.Sprintf("overlay-%d", len(a.overlays)) - a.overlays = append(a.overlays, overlay{page: name, focus: a.app.GetFocus()}) + a.overlays = append(a.overlays, overlay{page: name, focus: a.app.GetFocus(), hints: hints}) a.pages.AddPage(name, p, true, true) a.app.SetFocus(focus) a.renderStatus() @@ -194,12 +202,12 @@ func (a *App) openModal(p tview.Primitive, width, height int) { AddItem(p, height, 0, true). AddItem(nil, 0, 1, false), width, 0, true). AddItem(nil, 0, 1, false) - a.pushOverlay(box, p) + a.pushOverlay(box, p, "Tab/arrows move · Enter select · Esc cancel") } // openFull shows p above the whole view area (the tab bar and status bar stay visible). -func (a *App) openFull(p tview.Primitive) { - a.pushOverlay(p, p) +func (a *App) openFull(p tview.Primitive, hints string) { + a.pushOverlay(p, p, hints) } // closeModal removes the topmost overlay and restores the previous focus. @@ -294,6 +302,7 @@ func (a *App) switchTo(i int) { a.active = i a.pages.SwitchToPage(a.views[i].name) a.renderTabs() + a.renderStatus() } func (a *App) renderTabs() { @@ -302,8 +311,18 @@ func (a *App) renderTabs() { if i == a.active { s += fmt.Sprintf("[black:white] %c %s [-:-] ", v.key, v.name) } else { - s += fmt.Sprintf("[gray] %c %s [-] ", v.key, v.name) + s += fmt.Sprintf("[gray] %c %s [-]", v.key, v.name) } } a.tabs.SetText(s) } + +func searchHints() string { + return "/ search · Tab list/search · Enter add · i releases" +} + +func libraryHints() string { + return "m kind · f filter · / text · t monitor · s search · Enter open" +} + +func logHints() string { return "↑↓ scroll · G follow · c clear" } diff --git a/internal/ui/downloads.go b/internal/ui/downloads.go index e327c98..0b58468 100644 --- a/internal/ui/downloads.go +++ b/internal/ui/downloads.go @@ -3,7 +3,6 @@ package ui import ( "context" "fmt" - "strings" "sync" "time" @@ -124,6 +123,7 @@ func newDownloadsView(app *App, sab *sabnzbd.Client, sources []arrSource, interv // nextPanel moves focus to the next (dir 1) or previous (dir -1) panel: // queue, Radarr/Sonarr queue, history. func (v *downloadsView) nextPanel(dir int) { + defer v.app.renderStatus() cur := v.app.app.GetFocus() for i, p := range v.panels { if p == cur { @@ -201,11 +201,8 @@ func (v *downloadsView) refresh(ctx context.Context) { } v.app.post(func() { - var errs []string v.app.SetService("SABnzbd", "", qerr) - if qerr != nil { - errs = append(errs, "SABnzbd: "+qerr.Error()) - } else { + if qerr == nil { v.sabQueue, v.slots, v.paused = queue, queue.Slots, queue.Paused v.renderQueue() } @@ -215,18 +212,13 @@ func (v *downloadsView) refresh(ctx context.Context) { } for i, s := range v.sources { v.app.SetService(s.name, "", arrs[i].err) - if arrs[i].err != nil { - errs = append(errs, s.name+": "+arrs[i].err.Error()) - continue + if arrs[i].err == nil { + v.arrItems[s.name] = arrs[i].items } - v.arrItems[s.name] = arrs[i].items } v.rebuildArrRows() v.renderArr() v.renderSummary() - if len(errs) > 0 { - v.app.SetMessage("[red]" + tview.Escape(strings.Join(errs, " · "))) - } }) } @@ -524,3 +516,15 @@ func (v *downloadsView) removeArr(blocklist bool) { }) }) } + +// hints lists the keys of the focused panel. +func (v *downloadsView) hints() string { + common := "p pause/resume · r refresh · Tab next panel" + switch v.app.app.GetFocus() { + case v.arrQueue: + return "d remove · b remove + blocklist · " + common + case v.history: + return "d delete · t retry failed · " + common + } + return "d delete job · " + common +} diff --git a/internal/ui/episodes.go b/internal/ui/episodes.go index 21da8e7..763e28f 100644 --- a/internal/ui/episodes.go +++ b/internal/ui/episodes.go @@ -54,14 +54,11 @@ func (a *App) openSeries(c *sonarr.Client, s sonarr.Series, onChange func()) { detail: tview.NewTextView().SetDynamicColors(true).SetWordWrap(true), expanded: map[int]bool{}, } - foot := tview.NewTextView().SetDynamicColors(true). - SetText("[gray]Enter expand / releases · i releases · s search · t monitor · r reload · Esc back") v.table.SetBorder(true) v.root = tview.NewFlex().SetDirection(tview.FlexRow). AddItem(v.header, 1, 0, false). AddItem(v.table, 0, 1, true). - AddItem(v.detail, 2, 0, false). - AddItem(foot, 1, 0, false) + AddItem(v.detail, 2, 0, false) v.table.SetSelectionChangedFunc(func(int, int) { v.renderDetail() }) v.table.SetSelectedFunc(func(int, int) { v.enter() }) @@ -89,7 +86,7 @@ func (a *App) openSeries(c *sonarr.Client, s sonarr.Series, onChange func()) { }) v.render() - a.openFull(v.root) + a.openFull(v.root, "Enter expand/releases · i releases · s search · t monitor · r reload · Esc back") v.reload() } diff --git a/internal/ui/help.go b/internal/ui/help.go index cfdbc5b..151f90c 100644 --- a/internal/ui/help.go +++ b/internal/ui/help.go @@ -6,7 +6,7 @@ import ( ) const helpText = `[yellow]Global[-] - F1-F4 switch view + F1-F5 switch view Tab, Shift+Tab switch panel within a view ?, F9 this help (F9 works while typing) q, Ctrl+C quit @@ -43,6 +43,10 @@ const helpText = `[yellow]Global[-] t retry a failed download (history) r refresh now +[yellow]Log (F5)[-] + Popups fade away by themselves; everything they said is kept here. + ↑↓, PgUp/PgDn scroll G follow new entries c clear + [yellow]Connections[-] ` diff --git a/internal/ui/logs.go b/internal/ui/logs.go new file mode 100644 index 0000000..f80873e --- /dev/null +++ b/internal/ui/logs.go @@ -0,0 +1,61 @@ +package ui + +import ( + "fmt" + "time" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +const maxLogLines = 1000 + +// logView keeps the history of everything that was reported as a popup. +type logView struct { + root *tview.TextView + app *App + follow bool +} + +func newLogView(app *App) *logView { + v := &logView{app: app, follow: true} + v.root = tview.NewTextView().SetDynamicColors(true).SetScrollable(true).SetMaxLines(maxLogLines) + v.root.SetBorder(true).SetTitle(" Log ") + v.root.SetInputCapture(func(ev *tcell.EventKey) *tcell.EventKey { + switch ev.Key() { + case tcell.KeyUp, tcell.KeyPgUp, tcell.KeyHome: + v.follow = false + case tcell.KeyEnd: + v.follow = true + case tcell.KeyRune: + switch ev.Rune() { + case 'c': + v.root.Clear() + return nil + case 'k': + v.follow = false + case 'G': + v.follow = true + v.root.ScrollToEnd() + return nil + } + } + return ev + }) + return v +} + +// add appends an entry. +func (v *logView) add(l level, text string) { + color := map[level]string{levelInfo: "white", levelSuccess: "green", levelWarn: "yellow", levelError: "red"}[l] + fmt.Fprintf(v.root, "[gray]%s[-] [%s]%-5s[-] %s\n", time.Now().Format("15:04:05"), color, l, tview.Escape(text)) + if v.follow { + v.root.ScrollToEnd() + } +} + +func (v *logView) onShow() { + if v.follow { + v.root.ScrollToEnd() + } +} diff --git a/internal/ui/notify.go b/internal/ui/notify.go new file mode 100644 index 0000000..48a42ad --- /dev/null +++ b/internal/ui/notify.go @@ -0,0 +1,155 @@ +package ui + +import ( + "regexp" + "strings" + "time" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +type level int + +const ( + levelInfo level = iota + levelSuccess + levelWarn + levelError +) + +func (l level) String() string { + return [...]string{"INFO", "OK", "WARN", "ERROR"}[l] +} + +func (l level) color() tcell.Color { + return [...]tcell.Color{tcell.ColorWhite, tcell.ColorGreen, tcell.ColorYellow, tcell.ColorRed}[l] +} + +// how long a toast stays on screen, and how long the end of that time fades. +func (l level) lifetime() time.Duration { + return [...]time.Duration{3 * time.Second, 3 * time.Second, 5 * time.Second, 8 * time.Second}[l] +} + +const ( + toastFade = time.Second + maxToasts = 4 +) + +type toast struct { + text string + level level + expires time.Time +} + +// progress toasts ("Searching…") are replaced by the next message. +func (t toast) progress() bool { return strings.HasSuffix(t.text, "…") } + +var ( + tagRe = regexp.MustCompile(`\[([^\[\]]*)\[\]|\[[^\[\]]*\]`) + levelPrefix = []struct { + tag string + level level + }{{"[red]", levelError}, {"[green]", levelSuccess}, {"[yellow]", levelWarn}} +) + +// plainText removes tview color tags and undoes tview.Escape. +func plainText(s string) string { + return tagRe.ReplaceAllStringFunc(s, func(m string) string { + if strings.HasSuffix(m, "[]") { + return m[:len(m)-2] + "]" + } + return "" + }) +} + +// SetMessage reports something to the user: as a short-lived popup and, unless +// it is only progress ("Adding…"), as an entry in the log. A leading [red], +// [yellow] or [green] tag selects the level; an empty text dismisses progress +// popups. UI goroutine only; use post from other goroutines. +func (a *App) SetMessage(text string) { + if text == "" { + a.dropProgress() + return + } + lvl := levelInfo + for _, p := range levelPrefix { + if strings.HasPrefix(text, p.tag) { + lvl, text = p.level, strings.TrimPrefix(text, p.tag) + break + } + } + a.Notify(lvl, plainText(text)) +} + +// Notify shows a popup and logs the message. UI goroutine only. +func (a *App) Notify(l level, text string) { + t := toast{text: text, level: l, expires: time.Now().Add(l.lifetime())} + a.dropProgress() + if !t.progress() { + a.logs.add(l, text) + } + a.toasts = append(a.toasts, t) + if len(a.toasts) > maxToasts { + a.toasts = a.toasts[len(a.toasts)-maxToasts:] + } + // Redraw when the fade starts and when the toast is gone. + for _, d := range []time.Duration{l.lifetime() - toastFade, l.lifetime() + 50*time.Millisecond} { + time.AfterFunc(d, func() { a.post(func() {}) }) + } +} + +func (a *App) dropProgress() { + kept := a.toasts[:0] + for _, t := range a.toasts { + if !t.progress() { + kept = append(kept, t) + } + } + a.toasts = kept +} + +// toastRoot draws the popups above the whole interface. +type toastRoot struct { + *tview.Flex + app *App +} + +func (r *toastRoot) Draw(screen tcell.Screen) { + r.Flex.Draw(screen) + a := r.app + now := time.Now() + kept := a.toasts[:0] + for _, t := range a.toasts { + if now.Before(t.expires) { + kept = append(kept, t) + } + } + a.toasts = kept + + w, h := screen.Size() + y := h - 3 // above the status bar + for i := len(a.toasts) - 1; i >= 0 && y >= 1; i-- { + t := a.toasts[i] + text := " " + t.text + " " + width := min(tview.TaggedStringWidth(text), w-2) + x := w - width - 1 + fg := t.level.color() + if t.expires.Sub(now) < toastFade { + fg = tcell.ColorGray // fade out + } + style := tcell.StyleDefault.Background(tcell.NewRGBColor(30, 30, 40)).Foreground(fg) + for cx := x; cx < x+width; cx++ { + screen.SetContent(cx, y, ' ', nil, style) + } + col := x + for _, ch := range text { + if col >= x+width { + break + } + screen.SetContent(col, y, ch, nil, style) + col += tview.TaggedStringWidth(string(ch)) + } + y-- + } +} diff --git a/internal/ui/notify_test.go b/internal/ui/notify_test.go new file mode 100644 index 0000000..f6e30ae --- /dev/null +++ b/internal/ui/notify_test.go @@ -0,0 +1,26 @@ +package ui + +import ( + "testing" + + "github.com/rivo/tview" +) + +func TestPlainText(t *testing.T) { + for _, tc := range []struct{ in, want string }{ + {"[green]Done[-]", "Done"}, + {"[red]" + tview.Escape("Movie [1080p] failed"), "Movie [1080p] failed"}, + {"plain", "plain"}, + {"[yellow]a [::b]b[::-]", "a b"}, + } { + if got := plainText(tc.in); got != tc.want { + t.Errorf("plainText(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestToastProgress(t *testing.T) { + if !(toast{text: "Searching…"}).progress() || (toast{text: "Done"}).progress() { + t.Error("progress detection wrong") + } +} diff --git a/internal/ui/releases.go b/internal/ui/releases.go index 4997cc5..373efbe 100644 --- a/internal/ui/releases.go +++ b/internal/ui/releases.go @@ -54,14 +54,11 @@ func (a *App) openReleases(title string, fetch func(context.Context) ([]arr.Rele table: tview.NewTable().SetSelectable(true, false).SetFixed(1, 0), detail: tview.NewTextView().SetDynamicColors(true).SetWordWrap(true), } - foot := tview.NewTextView().SetDynamicColors(true). - SetText("[gray]Enter/g grab · s sort · f hide rejected · Esc back") v.table.SetBorder(true) v.root = tview.NewFlex().SetDirection(tview.FlexRow). AddItem(v.header, 1, 0, false). AddItem(v.table, 0, 1, true). - AddItem(v.detail, 3, 0, false). - AddItem(foot, 1, 0, false) + AddItem(v.detail, 3, 0, false) v.table.SetSelectionChangedFunc(func(int, int) { v.renderDetail() }) v.table.SetSelectedFunc(func(int, int) { v.grabSelected() }) @@ -86,7 +83,7 @@ func (a *App) openReleases(title string, fetch func(context.Context) ([]arr.Rele ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) v.cancel = cancel v.render() // shows the "searching" placeholder - a.openFull(v.root) + a.openFull(v.root, "Enter/g grab · s sort · f hide rejected · Esc back") go func() { releases, err := fetch(ctx)