package ui import ( "context" "fmt" "time" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" "git.stinnesbeck.com/nils/arr-tui/internal/sabnzbd" ) const historyLimit = 25 // downloadsView shows the SABnzbd queue and history and polls while visible. type downloadsView struct { app *App sab *sabnzbd.Client interval time.Duration root *tview.Flex summary *tview.TextView queue *tview.Table history *tview.Table // UI-goroutine state. slots []sabnzbd.Slot done []sabnzbd.HistorySlot paused bool cancel context.CancelFunc // stops the poller } func newDownloadsView(app *App, sab *sabnzbd.Client, interval time.Duration) *downloadsView { v := &downloadsView{ app: app, sab: sab, interval: interval, summary: tview.NewTextView().SetDynamicColors(true), queue: tview.NewTable().SetSelectable(true, false).SetFixed(1, 0), history: tview.NewTable().SetSelectable(true, false).SetFixed(1, 0), } v.queue.SetBorder(true).SetTitle(" SABnzbd queue ") v.history.SetBorder(true).SetTitle(" SABnzbd history ") v.root = tview.NewFlex().SetDirection(tview.FlexRow). AddItem(v.summary, 1, 0, false). AddItem(v.queue, 0, 3, true). AddItem(v.history, 0, 2, false) v.queue.SetInputCapture(func(ev *tcell.EventKey) *tcell.EventKey { if ev.Key() != tcell.KeyRune { return ev } switch ev.Rune() { case 'p': v.togglePause() case 'd': v.deleteSelected() case 'r': v.refreshNow() case 'h': v.app.app.SetFocus(v.history) default: return ev } return nil }) v.history.SetInputCapture(func(ev *tcell.EventKey) *tcell.EventKey { if ev.Key() != tcell.KeyRune { return ev } switch ev.Rune() { case 't': v.retrySelected() case 'd': v.deleteSelected() case 'r': v.refreshNow() case 'h': v.app.app.SetFocus(v.queue) case 'p': v.togglePause() default: return ev } return nil }) v.renderQueue() v.renderHistory() return v } // 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) }() } // refresh fetches queue and history off the UI goroutine and applies them. func (v *downloadsView) refresh(ctx context.Context) { q, err := v.sab.Queue(ctx) if err != nil { if ctx.Err() == nil { v.app.post(func() { v.app.SetMessage("[red]SABnzbd: " + tview.Escape(err.Error())) }) } return } h, herr := v.sab.History(ctx, historyLimit) if ctx.Err() != nil { return } v.app.post(func() { v.slots, v.paused = q.Slots, q.Paused v.renderSummary(q) v.renderQueue() if herr == nil { v.done = h v.renderHistory() } }) } func (v *downloadsView) renderSummary(q sabnzbd.Queue) { 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) } } v.summary.SetText(s) } func header(t *tview.Table, cols ...string) { for i, c := range cols { cell := tview.NewTableCell(c).SetTextColor(tcell.ColorYellow).SetSelectable(false) if i == 0 { cell.SetExpansion(1) } t.SetCell(0, i, cell) } } 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, "Name", "Category", "Status", "Size", "Progress", "ETA") row := 1 for i, s := range v.slots { cells := []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), } for c, text := range cells { cell := tview.NewTableCell(text) if c == 0 { cell.SetExpansion(1) } v.queue.SetCell(row, c, cell) } if s.ID == sel { v.queue.Select(i+1, 0) } row++ } if len(v.slots) == 0 { v.queue.SetCell(1, 0, tview.NewTableCell("[gray]Queue is empty").SetSelectable(false)) v.queue.Select(0, 0) } else if sel == "" { v.queue.Select(1, 0) } } 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, "Name", "Category", "Status", "Size", "Finished") now := time.Now() 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, 28)) + ")[-]" } case h.Status == "Completed": status = "[green]" + status + "[-]" } cells := []string{ tview.Escape(h.Name), tview.Escape(h.Category), status, tview.Escape(h.Size), formatAge(h.Completed, now), } for c, text := range cells { cell := tview.NewTableCell(text) if c == 0 { cell.SetExpansion(1) } v.history.SetCell(i+1, c, cell) } if h.ID == sel { v.history.Select(i+1, 0) } } if len(v.done) == 0 { v.history.SetCell(1, 0, tview.NewTableCell("[gray]No history").SetSelectable(false)) v.history.Select(0, 0) } else if sel == "" { v.history.Select(1, 0) } } // act runs a SABnzbd 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]SABnzbd: " + tview.Escape(err.Error())) return } v.app.SetMessage("[green]" + done) }) if err == nil { v.refresh(ctx) } }() } func (v *downloadsView) togglePause() { if v.paused { v.act("Resuming…", "Queue resumed", v.sab.Resume) } else { v.act("Pausing…", "Queue paused", v.sab.Pause) } } func (v *downloadsView) deleteSelected() { if v.app.app.GetFocus() == v.history { 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 { return v.sab.DeleteHistory(ctx, h.ID, false) }) }) return } 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 queue?", func() { v.act("Deleting…", "Deleted "+tview.Escape(s.Name), func(ctx context.Context) error { return v.sab.DeleteJob(ctx, s.ID, false) }) }) } func (v *downloadsView) retrySelected() { 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 { return v.sab.Retry(ctx, h.ID) }) }