Files
arr-tui/internal/ui/downloads.go
T
nilsandClaude Opus 5.5 820b17842b Fixed dark and light themes, ui.theme auto/dark/light
Colors no longer come from the terminal's palette, whose color 0 many
color themes set to a grey that is not their background. A theme sets
tview's default styles and the color names used in texts, all chosen from
entries 16-255 of the 256-color table so terminals without 24-bit color
show them exactly. "auto" picks light or dark from the background color
the terminal reports. Settable in the Config view, applied at the next
start (widgets copy their colors when created). Replaces using the
terminal's own background and text colors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
2026-10-21 12:05:00 +02:00

539 lines
14 KiB
Go

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])
}
// setInterval changes the polling interval, also while polling.
func (v *downloadsView) setInterval(d time.Duration) {
v.interval = d
if v.cancel != nil {
v.activate()
}
}
// 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(named("yellow")).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
}