Files
arr-tui/internal/ui/releases.go
T
nilsandClaude Sonnet 5.5 0b4af81203 Fix UI freeze when pressing Down in an empty table
tview's table navigation never terminates when no row is selectable. Turn
selection off while the Downloads panels and the release list are empty.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 19:18:24 +02:00

338 lines
8.7 KiB
Go

package ui
import (
"cmp"
"context"
"fmt"
"slices"
"strings"
"time"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
)
// Sort modes of the release list; sortLabels[i] names mode i.
const (
sortBest = iota
sortSize
sortAge
numSortModes
)
var sortLabels = [numSortModes]string{"best", "size", "age"}
// releasesView is the full-screen interactive search result list.
type releasesView struct {
app *App
title string
grab func(ctx context.Context, r arr.Release) error
root *tview.Flex
header *tview.TextView
table *tview.Table
detail *tview.TextView
cancel context.CancelFunc // aborts the running indexer search
all []arr.Release // usenet releases only
shown []arr.Release
torrents int // torrent releases left out
searched bool // the indexer search has finished
sortMode int
hideRejected bool
}
// openReleases starts an interactive search and shows the results as they arrive.
func (a *App) openReleases(title string, fetch func(context.Context) ([]arr.Release, error), grab func(context.Context, arr.Release) error) {
v := &releasesView{
app: a,
title: title,
grab: grab,
header: tview.NewTextView().SetDynamicColors(true),
table: tview.NewTable().SetSelectable(true, false).SetFixed(1, 0),
detail: tview.NewTextView().SetDynamicColors(true).SetWordWrap(true),
}
foot := tview.NewTextView().SetDynamicColors(true).
SetText("[gray]Enter/g grab · s sort · f hide rejected · Esc back")
v.table.SetBorder(true)
v.root = tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(v.header, 1, 0, false).
AddItem(v.table, 0, 1, true).
AddItem(v.detail, 3, 0, false).
AddItem(foot, 1, 0, false)
v.table.SetSelectionChangedFunc(func(int, int) { v.renderDetail() })
v.table.SetSelectedFunc(func(int, int) { v.grabSelected() })
v.table.SetInputCapture(func(ev *tcell.EventKey) *tcell.EventKey {
switch {
case ev.Key() == tcell.KeyEscape:
v.close()
case ev.Key() == tcell.KeyRune && ev.Rune() == 'g':
v.grabSelected()
case ev.Key() == tcell.KeyRune && ev.Rune() == 's':
v.sortMode = (v.sortMode + 1) % numSortModes
v.render()
case ev.Key() == tcell.KeyRune && ev.Rune() == 'f':
v.hideRejected = !v.hideRejected
v.render()
default:
return ev
}
return nil
})
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
v.cancel = cancel
v.render() // shows the "searching" placeholder
a.openFull(v.root)
go func() {
releases, err := fetch(ctx)
if ctx.Err() == context.Canceled {
return
}
a.post(func() {
if err != nil {
v.header.SetText("[red]Search failed: " + tview.Escape(err.Error()))
v.table.Clear()
return
}
v.setReleases(releases)
})
}()
}
func (v *releasesView) close() {
v.cancel()
v.app.closeModal()
}
func (v *releasesView) setReleases(releases []arr.Release) {
v.all, v.torrents = nil, 0
for _, r := range releases {
// Downloads go to SABnzbd, which can only handle Usenet.
if r.Protocol == "torrent" {
v.torrents++
continue
}
v.all = append(v.all, r)
}
v.searched = true
v.render()
}
// render rebuilds the table, keeping the selected release selected.
func (v *releasesView) render() {
selGUID := ""
if r, ok := v.selected(); ok {
selGUID = r.GUID
}
v.shown = visibleReleases(v.all, v.hideRejected, v.sortMode)
v.table.Clear()
for i, c := range []string{"", "Title", "Quality", "Size", "Age", "Indexer", "Score"} {
cell := tview.NewTableCell(c).SetTextColor(tcell.ColorYellow).SetSelectable(false)
if c == "Title" {
cell.SetExpansion(1)
}
v.table.SetCell(0, i, cell)
}
selRow := 0
for i, r := range v.shown {
cells := []string{
releaseMark(r), tview.Escape(r.Title), tview.Escape(r.QualityName()), formatSize(r.Size),
formatReleaseAge(r.AgeMinutes), tview.Escape(r.Indexer), fmt.Sprint(r.CustomFormatScore),
}
for c, text := range cells {
cell := tview.NewTableCell(text)
if c == 1 {
cell.SetExpansion(1)
}
v.table.SetCell(i+1, c, cell)
}
if r.GUID == selGUID {
selRow = i + 1
}
}
switch {
case !v.searched:
v.table.SetCell(1, 1, tview.NewTableCell("[gray]Searching indexers… (Esc to cancel)").SetSelectable(false))
case len(v.shown) == 0:
msg := "No releases found. Are indexers configured?"
if len(v.all) > 0 {
msg = "All releases are hidden by the filter (f)"
}
v.table.SetCell(1, 1, tview.NewTableCell("[gray]"+msg).SetSelectable(false))
}
// With no selectable row tview's Down/Up handling never terminates.
v.table.SetSelectable(len(v.shown) > 0, false)
if len(v.shown) > 0 {
v.table.Select(max(selRow, 1), 0)
} else {
v.table.Select(0, 0)
}
v.renderHeader()
v.renderDetail()
}
func (v *releasesView) renderHeader() {
s := "[::b]" + v.title + "[::-]" // callers escape the title
if v.searched {
s += fmt.Sprintf(" · %d of %d releases", len(v.shown), len(v.all))
}
s += " · sort: " + sortLabels[v.sortMode]
if v.hideRejected {
s += " · [yellow]rejected hidden[-]"
}
if v.torrents > 0 {
s += fmt.Sprintf(" · [gray]%d torrent release%s hidden[-]", v.torrents, plural(v.torrents))
}
v.header.SetText(s)
}
func (v *releasesView) renderDetail() {
r, ok := v.selected()
if !ok {
v.detail.SetText("")
return
}
var lines []string
switch {
case len(r.Rejections) > 0:
lines = append(lines, "[red]Rejected:[-] "+tview.Escape(strings.Join(r.Rejections, "; ")))
case r.Approved:
lines = append(lines, "[green]Approved[-]")
}
var facts []string
if r.ReleaseGroup != "" {
facts = append(facts, "group "+tview.Escape(r.ReleaseGroup))
}
if l := r.LanguageNames(); l != "" {
facts = append(facts, tview.Escape(l))
}
if len(facts) > 0 {
lines = append(lines, "[gray]"+strings.Join(facts, " · ")+"[-]")
}
v.detail.SetText(strings.Join(lines, "\n"))
}
func (v *releasesView) selected() (arr.Release, bool) {
row, _ := v.table.GetSelection()
if row < 1 || row > len(v.shown) {
return arr.Release{}, false
}
return v.shown[row-1], true
}
// grabSelected sends the selected release to the download client; rejected
// releases need an explicit confirmation first.
func (v *releasesView) grabSelected() {
r, ok := v.selected()
if !ok {
return
}
if len(r.Rejections) == 0 && !r.Rejected {
v.doGrab(r)
return
}
reasons := r.Rejections
extra := ""
if len(reasons) > 3 {
extra = fmt.Sprintf("\n+%d more", len(reasons)-3)
reasons = reasons[:3]
}
v.app.confirm("This release was rejected:\n"+tview.Escape(strings.Join(reasons, "\n"))+extra+"\n\nGrab it anyway?",
func() { v.doGrab(r) })
}
func (v *releasesView) doGrab(r arr.Release) {
v.app.SetMessage("Grabbing " + tview.Escape(truncate(r.Title, 60)) + "…")
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
err := v.grab(ctx, r)
v.app.post(func() {
if err != nil {
v.app.SetMessage("[red]Grab failed: " + tview.Escape(err.Error()))
return
}
v.app.SetMessage("[green]Sent to the download client: " + tview.Escape(truncate(r.Title, 60)))
})
}()
}
// visibleReleases applies the filter and sort order, returning a new slice.
func visibleReleases(all []arr.Release, hideRejected bool, mode int) []arr.Release {
out := make([]arr.Release, 0, len(all))
for _, r := range all {
if hideRejected && isRejected(r) {
continue
}
out = append(out, r)
}
slices.SortStableFunc(out, func(a, b arr.Release) int {
switch mode {
case sortSize:
return cmp.Compare(b.Size, a.Size)
case sortAge:
return cmp.Compare(a.AgeMinutes, b.AgeMinutes)
}
// best: approved first, then format score, quality, and newest
return cmp.Or(
cmp.Compare(boolInt(isRejected(a)), boolInt(isRejected(b))),
cmp.Compare(b.CustomFormatScore, a.CustomFormatScore),
cmp.Compare(b.QualityWeight, a.QualityWeight),
cmp.Compare(a.AgeMinutes, b.AgeMinutes),
)
})
return out
}
func isRejected(r arr.Release) bool { return r.Rejected || len(r.Rejections) > 0 }
func boolInt(b bool) int {
if b {
return 1
}
return 0
}
// releaseMark is the status glyph in the first column.
func releaseMark(r arr.Release) string {
switch {
case !isRejected(r):
return "[green]✓[-]"
case r.TemporarilyRejected:
return "[yellow]~[-]"
}
return "[red]✗[-]"
}
// formatSize renders a byte count as "1.4 GB".
func formatSize(b int64) string {
const k = 1024.0
f := float64(b)
switch {
case f >= k*k*k:
return fmt.Sprintf("%.1f GB", f/(k*k*k))
case f >= k*k:
return fmt.Sprintf("%.0f MB", f/(k*k))
case f > 0:
return fmt.Sprintf("%.0f KB", f/k)
}
return "-"
}
// formatReleaseAge renders an age given in minutes as "45m", "5h" or "3d".
func formatReleaseAge(minutes float64) string {
switch {
case minutes < 60:
return fmt.Sprintf("%dm", int(minutes))
case minutes < 48*60:
return fmt.Sprintf("%dh", int(minutes/60))
}
return fmt.Sprintf("%dd", int(minutes/(24*60)))
}