package ui import ( "context" "fmt" "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/sabnzbd" ) const historyLimit = 25 // arrSource is a Radarr or Sonarr instance whose download queue is shown. type arrSource struct { name string queue func(ctx context.Context) ([]arr.QueueItem, error) remove func(ctx context.Context, id int, blocklist bool) error } // arrRow is one row of the Radarr/Sonarr queue panel. type arrRow struct { source arrSource item arr.QueueItem } // downloadsView shows the SABnzbd queue and history plus the Radarr/Sonarr // queues, and polls while visible. type downloadsView struct { app *App sab *sabnzbd.Client sources []arrSource interval time.Duration root *tview.Flex summary *tview.TextView queue *tview.Table arrQueue *tview.Table arrDetail *tview.TextView history *tview.Table panels []tview.Primitive // focus cycle order // UI-goroutine state. slots []sabnzbd.Slot done []sabnzbd.HistorySlot arrItems map[string][]arr.QueueItem // last good result per source arrRows []arrRow sabQueue sabnzbd.Queue paused bool cancel context.CancelFunc // stops the poller } func newDownloadsView(app *App, sab *sabnzbd.Client, sources []arrSource, interval time.Duration) *downloadsView { table := func(title string) *tview.Table { t := tview.NewTable().SetSelectable(true, false).SetFixed(1, 0) t.SetBorder(true).SetTitle(title) return t } v := &downloadsView{ app: app, sab: sab, sources: sources, interval: interval, summary: tview.NewTextView().SetDynamicColors(true), queue: table(" SABnzbd queue "), arrQueue: table(" Radarr / Sonarr queue "), arrDetail: tview.NewTextView().SetDynamicColors(true).SetWordWrap(true), history: table(" SABnzbd history "), arrItems: map[string][]arr.QueueItem{}, } v.panels = []tview.Primitive{v.queue, v.arrQueue, v.history} v.root = tview.NewFlex().SetDirection(tview.FlexRow). AddItem(v.summary, 1, 0, false). AddItem(v.queue, 0, 3, true). AddItem(v.arrQueue, 0, 3, false). AddItem(v.arrDetail, 2, 0, false). AddItem(v.history, 0, 2, false) common := func(extra map[rune]func()) func(*tcell.EventKey) *tcell.EventKey { keys := map[rune]func(){ 'r': v.refreshNow, 'p': v.togglePause, } for r, f := range extra { keys[r] = f } return func(ev *tcell.EventKey) *tcell.EventKey { switch ev.Key() { case tcell.KeyTab: v.nextPanel(1) return nil case tcell.KeyBacktab: v.nextPanel(-1) return nil } if ev.Key() == tcell.KeyRune { if f, ok := keys[ev.Rune()]; ok { f() return nil } } return ev } } v.queue.SetInputCapture(common(map[rune]func(){'d': v.deleteJob})) v.arrQueue.SetInputCapture(common(map[rune]func(){ 'd': func() { v.removeArr(false) }, 'b': func() { v.removeArr(true) }, })) v.history.SetInputCapture(common(map[rune]func(){'d': v.deleteHistory, 't': v.retryHistory})) v.arrQueue.SetSelectionChangedFunc(func(int, int) { v.renderArrDetail() }) v.renderQueue() v.renderArr() v.renderHistory() return v } // 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 { v.app.app.SetFocus(v.panels[(i+dir+len(v.panels))%len(v.panels)]) return } } v.app.app.SetFocus(v.panels[0]) } // activate starts polling; called when the view becomes visible. func (v *downloadsView) activate() { v.deactivate() ctx, cancel := context.WithCancel(context.Background()) v.cancel = cancel go v.poll(ctx) } // deactivate stops polling; called when the view is hidden. func (v *downloadsView) deactivate() { if v.cancel != nil { v.cancel() v.cancel = nil } } func (v *downloadsView) poll(ctx context.Context) { v.refresh(ctx) t := time.NewTicker(v.interval) defer t.Stop() for { select { case <-ctx.Done(): return case <-t.C: v.refresh(ctx) } } } func (v *downloadsView) refreshNow() { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) go func() { defer cancel() v.refresh(ctx) }() } type arrResult struct { items []arr.QueueItem err error } // refresh fetches everything in parallel off the UI goroutine and applies the // parts that succeeded. A failing service keeps its last data and reports an error. func (v *downloadsView) refresh(ctx context.Context) { var ( wg sync.WaitGroup queue sabnzbd.Queue qerr error history []sabnzbd.HistorySlot herr error arrs = make([]arrResult, len(v.sources)) ) wg.Add(2) go func() { defer wg.Done(); queue, qerr = v.sab.Queue(ctx) }() go func() { defer wg.Done(); history, herr = v.sab.History(ctx, historyLimit) }() for i, s := range v.sources { wg.Add(1) go func() { defer wg.Done(); arrs[i].items, arrs[i].err = s.queue(ctx) }() } wg.Wait() if ctx.Err() != nil { return } v.app.post(func() { v.app.SetService("SABnzbd", "", qerr) if qerr == nil { v.sabQueue, v.slots, v.paused = queue, queue.Slots, queue.Paused v.renderQueue() } if herr == nil { v.done = history v.renderHistory() } for i, s := range v.sources { v.app.SetService(s.name, "", arrs[i].err) if arrs[i].err == nil { v.arrItems[s.name] = arrs[i].items } } v.rebuildArrRows() v.renderArr() v.renderSummary() }) } func (v *downloadsView) rebuildArrRows() { v.arrRows = v.arrRows[:0] for _, s := range v.sources { for _, it := range v.arrItems[s.name] { v.arrRows = append(v.arrRows, arrRow{source: s, item: it}) } } } func (v *downloadsView) renderSummary() { q := v.sabQueue var s string switch { case q.Paused: s = "[yellow]⏸ Paused[-]" case float64(q.KBPerSec) > 0: s = fmt.Sprintf("[green]↓ %s[-]", formatSpeed(float64(q.KBPerSec))) default: s = "[gray]Idle[-]" } s += fmt.Sprintf(" · %d job%s", q.TotalSlots, plural(q.TotalSlots)) if q.TotalSlots > 0 { s += fmt.Sprintf(" · %s left", tview.Escape(q.SizeLeft)) if !q.Paused && float64(q.KBPerSec) > 0 { s += " · ETA " + tview.Escape(q.TimeLeft) } } attention := 0 for _, r := range v.arrRows { if r.item.NeedsAttention() { attention++ } } if attention > 0 { verb := "need" if attention == 1 { verb = "needs" } s += fmt.Sprintf(" · [red]⚠ %d %s attention[-]", attention, verb) } v.summary.SetText(s) } // header writes the header row; column expand grows to fill the width. func header(t *tview.Table, expand int, cols ...string) { for i, c := range cols { cell := tview.NewTableCell(c).SetTextColor(tcell.ColorYellow).SetSelectable(false) if i == expand { cell.SetExpansion(1) } t.SetCell(0, i, cell) } } // fill writes data rows starting at row 1; column expand grows to fill the width. func fill(t *tview.Table, expand int, rows [][]string) { for r, cells := range rows { for c, text := range cells { cell := tview.NewTableCell(text) if c == expand { cell.SetExpansion(1) } t.SetCell(r+1, c, cell) } } } // restoreSelection selects the row whose id matches sel (or the first row) and // shows placeholder text when there are no rows. func restoreSelection(t *tview.Table, n, selRow int, placeholder string) { if n == 0 { // With no selectable row, tview's Down/Up handling never terminates, // so turn selection off entirely. t.SetCell(1, 0, tview.NewTableCell("[gray]"+placeholder).SetSelectable(false)) t.SetSelectable(false, false) t.Select(0, 0) return } t.SetSelectable(true, false) t.Select(max(selRow, 1), 0) } func (v *downloadsView) renderQueue() { sel := "" if r, _ := v.queue.GetSelection(); r >= 1 && r <= len(v.slots) { sel = v.slots[r-1].ID } v.queue.Clear() header(v.queue, 0, "Name", "Category", "Status", "Size", "Progress", "ETA") rows := make([][]string, len(v.slots)) selRow := 0 for i, s := range v.slots { rows[i] = []string{ tview.Escape(s.Name), tview.Escape(s.Category), tview.Escape(s.Status), tview.Escape(s.Size), progressBar(float64(s.Percentage), 10), tview.Escape(s.TimeLeft), } if s.ID == sel { selRow = i + 1 } } fill(v.queue, 0, rows) restoreSelection(v.queue, len(rows), selRow, "Queue is empty") } func (v *downloadsView) renderArr() { sel := 0 if r, _ := v.arrQueue.GetSelection(); r >= 1 { sel = r } selKey := "" if sel >= 1 && sel <= len(v.arrRows) { selKey = arrKey(v.arrRows[sel-1]) } v.arrQueue.Clear() header(v.arrQueue, 1, "Source", "Title", "Status", "Progress", "Problem") rows := make([][]string, len(v.arrRows)) selRow := 0 for i, r := range v.arrRows { rows[i] = []string{ r.source.name, tview.Escape(r.item.Name()), arrStatusText(r.item), progressBar(r.item.Progress(), 10), tview.Escape(truncate(r.item.Problem(), 40)), } if arrKey(r) == selKey { selRow = i + 1 } } fill(v.arrQueue, 1, rows) restoreSelection(v.arrQueue, len(rows), selRow, "Nothing in the Radarr/Sonarr queues") v.renderArrDetail() } func arrKey(r arrRow) string { return fmt.Sprintf("%s/%d", r.source.name, r.item.ID) } // renderArrDetail shows the release title and full problem text of the selected item. func (v *downloadsView) renderArrDetail() { row, ok := v.selectedArr() if !ok { v.arrDetail.SetText("") return } text := "[gray]Release:[-] " + tview.Escape(row.item.Title) if p := row.item.Problem(); p != "" { text += "\n[yellow]" + tview.Escape(p) } v.arrDetail.SetText(text) } func (v *downloadsView) selectedArr() (arrRow, bool) { r, _ := v.arrQueue.GetSelection() if r < 1 || r > len(v.arrRows) { return arrRow{}, false } return v.arrRows[r-1], true } func (v *downloadsView) renderHistory() { sel := "" if r, _ := v.history.GetSelection(); r >= 1 && r <= len(v.done) { sel = v.done[r-1].ID } v.history.Clear() header(v.history, 0, "Name", "Category", "Status", "Size", "Finished") now := time.Now() rows := make([][]string, len(v.done)) selRow := 0 for i, h := range v.done { status := tview.Escape(h.Status) switch { case h.Failed(): status = "[red]" + status + "[-]" if h.FailMessage != "" { status += " [gray](" + tview.Escape(truncate(h.FailMessage, 60)) + ")[-]" } case h.Status == "Completed": status = "[green]" + status + "[-]" } rows[i] = []string{ tview.Escape(h.Name), tview.Escape(h.Category), status, tview.Escape(h.Size), formatAge(h.Completed, now), } if h.ID == sel { selRow = i + 1 } } fill(v.history, 0, rows) restoreSelection(v.history, len(rows), selRow, "No history") } // act runs an action off the UI goroutine, reports the outcome and refreshes. func (v *downloadsView) act(doing, done string, f func(ctx context.Context) error) { v.app.SetMessage(doing) go func() { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() err := f(ctx) v.app.post(func() { if err != nil { v.app.SetMessage("[red]" + tview.Escape(err.Error())) return } v.app.SetMessage("[green]" + done) }) if err == nil { v.refresh(ctx) } }() } func (v *downloadsView) togglePause() { wrap := func(f func(context.Context) error) func(context.Context) error { return func(ctx context.Context) error { if err := f(ctx); err != nil { return fmt.Errorf("SABnzbd: %w", err) } return nil } } if v.paused { v.act("Resuming…", "Queue resumed", wrap(v.sab.Resume)) } else { v.act("Pausing…", "Queue paused", wrap(v.sab.Pause)) } } func (v *downloadsView) deleteJob() { r, _ := v.queue.GetSelection() if r < 1 || r > len(v.slots) { return } s := v.slots[r-1] v.app.confirm("Delete "+tview.Escape(s.Name)+" from the SABnzbd queue?", func() { v.act("Deleting…", "Deleted "+tview.Escape(s.Name), func(ctx context.Context) error { if err := v.sab.DeleteJob(ctx, s.ID, false); err != nil { return fmt.Errorf("SABnzbd: %w", err) } return nil }) }) } func (v *downloadsView) deleteHistory() { r, _ := v.history.GetSelection() if r < 1 || r > len(v.done) { return } h := v.done[r-1] v.app.confirm("Remove "+tview.Escape(h.Name)+" from history?", func() { v.act("Removing…", "Removed "+tview.Escape(h.Name), func(ctx context.Context) error { if err := v.sab.DeleteHistory(ctx, h.ID, false); err != nil { return fmt.Errorf("SABnzbd: %w", err) } return nil }) }) } func (v *downloadsView) retryHistory() { r, _ := v.history.GetSelection() if r < 1 || r > len(v.done) { return } h := v.done[r-1] if !h.Failed() { v.app.SetMessage("[yellow]Only failed downloads can be retried") return } v.act("Retrying…", "Retrying "+tview.Escape(h.Name), func(ctx context.Context) error { if err := v.sab.Retry(ctx, h.ID); err != nil { return fmt.Errorf("SABnzbd: %w", err) } return nil }) } // removeArr removes the selected Radarr/Sonarr queue item, optionally blocklisting the release. func (v *downloadsView) removeArr(blocklist bool) { row, ok := v.selectedArr() if !ok { return } q := "Remove " + tview.Escape(row.item.Name()) + " from " + row.source.name + " and the download client?" done := "Removed " + tview.Escape(row.item.Name()) if blocklist { q = "Blocklist this release of " + tview.Escape(row.item.Name()) + " and search for another one?" done = "Blocklisted " + tview.Escape(row.item.Name()) } v.app.confirm(q, func() { v.act("Working…", done, func(ctx context.Context) error { if err := row.source.remove(ctx, row.item.ID, blocklist); err != nil { return fmt.Errorf("%s: %w", row.source.name, err) } return nil }) }) } // 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 }