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>
233 lines
5.9 KiB
Go
233 lines
5.9 KiB
Go
// Package ui contains the tview user interface.
|
|
package ui
|
|
|
|
import (
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/gdamore/tcell/v2"
|
|
"github.com/rivo/tview"
|
|
|
|
"git.stinnesbeck.com/nils/arr-tui/internal/config"
|
|
"git.stinnesbeck.com/nils/arr-tui/internal/radarr"
|
|
"git.stinnesbeck.com/nils/arr-tui/internal/sabnzbd"
|
|
"git.stinnesbeck.com/nils/arr-tui/internal/sonarr"
|
|
)
|
|
|
|
const modalPage = "modal"
|
|
|
|
type view struct {
|
|
key rune
|
|
name string
|
|
body tview.Primitive
|
|
// Optional hooks, called when the view becomes visible / hidden.
|
|
onShow, onHide func()
|
|
}
|
|
|
|
// App is the top-level arr-tui application.
|
|
type App struct {
|
|
app *tview.Application
|
|
tabs *tview.TextView
|
|
pages *tview.Pages
|
|
status *tview.TextView
|
|
views []view
|
|
active int
|
|
|
|
message string
|
|
modalOpen bool
|
|
modalFocus tview.Primitive
|
|
}
|
|
|
|
// New builds the application shell: a tab bar, one page per view and a status bar.
|
|
func New(cfg *config.Config) *App {
|
|
a := &App{
|
|
app: tview.NewApplication(),
|
|
tabs: tview.NewTextView().SetDynamicColors(true),
|
|
pages: tview.NewPages(),
|
|
status: tview.NewTextView().SetDynamicColors(true),
|
|
}
|
|
|
|
radarrClient := radarr.NewClient(cfg.Radarr.URL, cfg.Radarr.APIKey)
|
|
sonarrClient := sonarr.NewClient(cfg.Sonarr.URL, cfg.Sonarr.APIKey)
|
|
downloads := newDownloadsView(a, sabnzbd.NewClient(cfg.SABnzbd.URL, cfg.SABnzbd.APIKey),
|
|
[]arrSource{
|
|
{name: "Radarr", queue: radarrClient.Queue, remove: radarrClient.RemoveQueueItem},
|
|
{name: "Sonarr", queue: sonarrClient.Queue, remove: sonarrClient.RemoveQueueItem},
|
|
},
|
|
time.Duration(cfg.UI.RefreshSeconds)*time.Second)
|
|
a.views = []view{
|
|
{key: '1', name: "Movies", body: newSearchView(a, radarrBackend{radarrClient}, cfg.Radarr).root},
|
|
{key: '2', name: "Series", body: newSearchView(a, sonarrBackend{sonarrClient}, cfg.Sonarr).root},
|
|
{key: '3', name: "Downloads", body: downloads.root, onShow: downloads.activate, onHide: downloads.deactivate},
|
|
{key: '4', name: "Library", body: placeholder("Library (v0.2)")},
|
|
}
|
|
for i, v := range a.views {
|
|
a.pages.AddPage(v.name, v.body, true, i == 0)
|
|
}
|
|
|
|
layout := tview.NewFlex().SetDirection(tview.FlexRow).
|
|
AddItem(a.tabs, 1, 0, false).
|
|
AddItem(a.pages, 0, 1, true).
|
|
AddItem(a.status, 1, 0, false)
|
|
|
|
a.app.SetRoot(layout, true).EnableMouse(false)
|
|
a.app.SetInputCapture(a.handleKey)
|
|
a.switchTo(0)
|
|
a.renderStatus()
|
|
return a
|
|
}
|
|
|
|
// SetMessage shows a message in the status bar. UI goroutine only; use
|
|
// post from other goroutines.
|
|
func (a *App) SetMessage(text string) {
|
|
a.message = text
|
|
a.renderStatus()
|
|
}
|
|
|
|
// post runs f on the UI goroutine and redraws. Safe to call from any goroutine.
|
|
func (a *App) post(f func()) {
|
|
a.app.QueueUpdateDraw(f)
|
|
}
|
|
|
|
// Run starts the event loop and blocks until the application quits.
|
|
func (a *App) Run() error {
|
|
return a.app.Run()
|
|
}
|
|
|
|
func (a *App) renderStatus() {
|
|
hints := "[gray]q quit · Tab/1-4/F1-F4 views · / search"
|
|
if a.message == "" {
|
|
a.status.SetText(hints)
|
|
return
|
|
}
|
|
a.status.SetText(a.message + " [gray]│ " + hints)
|
|
}
|
|
|
|
// openModal shows p centered over the current view and remembers the focus.
|
|
func (a *App) openModal(p tview.Primitive, width, height int) {
|
|
a.modalFocus = a.app.GetFocus()
|
|
a.modalOpen = true
|
|
box := tview.NewFlex().
|
|
AddItem(nil, 0, 1, false).
|
|
AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
|
|
AddItem(nil, 0, 1, false).
|
|
AddItem(p, height, 0, true).
|
|
AddItem(nil, 0, 1, false), width, 0, true).
|
|
AddItem(nil, 0, 1, false)
|
|
a.pages.AddPage(modalPage, box, true, true)
|
|
a.app.SetFocus(p)
|
|
}
|
|
|
|
func (a *App) closeModal() {
|
|
a.pages.RemovePage(modalPage)
|
|
a.modalOpen = false
|
|
if a.modalFocus != nil {
|
|
a.app.SetFocus(a.modalFocus)
|
|
a.modalFocus = nil
|
|
}
|
|
}
|
|
|
|
// confirm asks a yes/no question; yes runs only if the user picks Delete/Yes.
|
|
func (a *App) confirm(text string, yes func()) {
|
|
m := tview.NewModal().
|
|
SetText(text).
|
|
AddButtons([]string{"Cancel", "Confirm"}). // Cancel first so a stray Enter is safe
|
|
SetDoneFunc(func(i int, _ string) {
|
|
a.closeModal()
|
|
if i == 1 {
|
|
yes()
|
|
}
|
|
})
|
|
a.openModal(m, 60, 9)
|
|
}
|
|
|
|
// typing reports whether keystrokes belong to a text field or dialog and must
|
|
// not trigger global shortcuts.
|
|
func (a *App) typing() bool {
|
|
if a.modalOpen {
|
|
return true
|
|
}
|
|
_, isInput := a.app.GetFocus().(*tview.InputField)
|
|
return isInput
|
|
}
|
|
|
|
func (a *App) handleKey(ev *tcell.EventKey) *tcell.EventKey {
|
|
if ev.Key() == tcell.KeyCtrlC {
|
|
a.app.Stop()
|
|
return nil
|
|
}
|
|
// View switching that also works while typing in a search box. Not while a dialog is open.
|
|
if !a.modalOpen {
|
|
if i := a.viewShortcut(ev); i >= 0 && i < len(a.views) {
|
|
a.switchTo(i)
|
|
return nil
|
|
}
|
|
}
|
|
if a.typing() {
|
|
return ev
|
|
}
|
|
switch ev.Key() {
|
|
case tcell.KeyTab:
|
|
a.switchTo((a.active + 1) % len(a.views))
|
|
return nil
|
|
case tcell.KeyBacktab:
|
|
a.switchTo((a.active + len(a.views) - 1) % len(a.views))
|
|
return nil
|
|
case tcell.KeyRune:
|
|
if ev.Rune() == 'q' {
|
|
a.app.Stop()
|
|
return nil
|
|
}
|
|
for i, v := range a.views {
|
|
if ev.Rune() == v.key {
|
|
a.switchTo(i)
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
return ev
|
|
}
|
|
|
|
// viewShortcut returns the view index for F1-F4 or Alt+1-4, or -1.
|
|
func (a *App) viewShortcut(ev *tcell.EventKey) int {
|
|
if ev.Key() >= tcell.KeyF1 && ev.Key() <= tcell.KeyF12 {
|
|
return int(ev.Key() - tcell.KeyF1)
|
|
}
|
|
if ev.Key() == tcell.KeyRune && ev.Modifiers()&tcell.ModAlt != 0 {
|
|
for i, v := range a.views {
|
|
if ev.Rune() == v.key {
|
|
return i
|
|
}
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func (a *App) switchTo(i int) {
|
|
if hide := a.views[a.active].onHide; hide != nil && i != a.active {
|
|
hide()
|
|
}
|
|
if show := a.views[i].onShow; show != nil && i != a.active {
|
|
show()
|
|
}
|
|
a.active = i
|
|
a.pages.SwitchToPage(a.views[i].name)
|
|
a.renderTabs()
|
|
}
|
|
|
|
func (a *App) renderTabs() {
|
|
var s string
|
|
for i, v := range a.views {
|
|
if i == a.active {
|
|
s += fmt.Sprintf("[black:white] %c %s [-:-] ", v.key, v.name)
|
|
} else {
|
|
s += fmt.Sprintf("[gray] %c %s [-] ", v.key, v.name)
|
|
}
|
|
}
|
|
a.tabs.SetText(s)
|
|
}
|
|
|
|
func placeholder(text string) tview.Primitive {
|
|
return tview.NewTextView().SetText(text).SetTextAlign(tview.AlignCenter)
|
|
}
|