Add queue listing and removal/blocklisting to the shared arr client. The Downloads view now fetches SABnzbd and both arr queues in parallel, shows status, progress and import problems, flags items needing attention in the summary, and supports removing or blocklisting items. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
513 lines
13 KiB
Go
513 lines
13 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
|
|
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 {
|
|
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 {
|
|
t.SetCell(1, 0, tview.NewTableCell("[gray]"+placeholder).SetSelectable(false))
|
|
t.Select(0, 0)
|
|
return
|
|
}
|
|
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, 28)) + ")[-]"
|
|
}
|
|
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
|
|
})
|
|
})
|
|
}
|