Add SABnzbd client and Downloads view (M4)
SABnzbd client (queue, history, pause/resume, delete, retry) with lenient number decoding and API-key-safe errors. Downloads view polls while visible, shows queue and history, and supports pause, delete (with confirmation) and retry. Add dev/ with pinned Radarr/Sonarr/SABnzbd containers wired together for manual testing. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user