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
539 lines
14 KiB
Go
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
|
|
}
|