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(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
|
|
})
|
|
})
|
|
}
|
|
|
|
// 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
|
|
}
|