Files
arr-tui/internal/ui/releases.go
T
nilsandClaude Opus 5.5 820b17842b Fixed dark and light themes, ui.theme auto/dark/light
Colors no longer come from the terminal's palette, whose color 0 many
color themes set to a grey that is not their background. A theme sets
tview's default styles and the color names used in texts, all chosen from
entries 16-255 of the 256-color table so terminals without 24-bit color
show them exactly. "auto" picks light or dark from the background color
the terminal reports. Settable in the Config view, applied at the next
start (widgets copy their colors when created). Replaces using the
terminal's own background and text colors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
2026-10-21 12:05:00 +02:00

364 lines
9.5 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
attr attrFilter
}
// 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),
}
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)
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.app.openAttrFilter(v.attr, v.filterPool, func(f attrFilter) {
v.attr = f
v.render()
}, nil)
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, "Enter/g grab · s sort · f hide rejected · F filter · Esc back", v.cancel)
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.app.closeModal() // cancels the running search via the overlay's cleanup
}
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.attr)
v.table.Clear()
for i, c := range []string{"", "Title", "Quality", "Size", "Age", "Indexer", "Score"} {
cell := tview.NewTableCell(c).SetTextColor(named("yellow")).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 filters (f, 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)
if selRow <= 1 {
// Otherwise the table can keep a stale scroll offset from the
// "searching" placeholder and show the middle of the list.
v.table.ScrollToBeginning()
}
} 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.attr.active() {
s += " · [yellow]" + v.attr.String() + "[-]"
}
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, af attrFilter) []arr.Release {
out := make([]arr.Release, 0, len(all))
for _, r := range all {
if hideRejected && isRejected(r) {
continue
}
if af.active() && !af.matchFile(r.Attrs()) {
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)))
}
// filterPool lists the properties of the releases that pass the rejected
// filter, for the filter dialog.
func (v *releasesView) filterPool() filterPool {
var p filterPool
for _, r := range v.all {
if v.hideRejected && isRejected(r) {
continue
}
p.files = append(p.files, []arr.FileAttrs{r.Attrs()})
}
return p
}