Files
arr-tui/internal/ui/downloads.go
T
nilsandClaude Sonnet 5.5 0b4af81203 Fix UI freeze when pressing Down in an empty table
tview's table navigation never terminates when no row is selectable. Turn
selection off while the Downloads panels and the release list are empty.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 19:18:24 +02:00

519 lines
14 KiB
Go

package ui
import (
"context"
"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/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,
'h': v.nextPanel,
'p': v.togglePause,
}
for r, f := range extra {
keys[r] = f
}
return func(ev *tcell.EventKey) *tcell.EventKey {
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 panel (queue, Radarr/Sonarr queue, history).
func (v *downloadsView) nextPanel() {
cur := v.app.app.GetFocus()
for i, p := range v.panels {
if p == cur {
v.app.app.SetFocus(v.panels[(i+1)%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() {
var errs []string
v.app.SetService("SABnzbd", "", qerr)
if qerr != nil {
errs = append(errs, "SABnzbd: "+qerr.Error())
} else {
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 {
errs = append(errs, s.name+": "+arrs[i].err.Error())
continue
}
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, " · ")))
}
})
}
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
})
})
}