Files
arr-tui/internal/ui/releases.go
T
2026-10-20 20:30:12 +02:00

367 lines
9.6 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, releaseLanguages(v.all), func(f attrFilter) {
v.attr = f
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, "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(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 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)))
}
// releaseLanguages lists the languages the releases announce, sorted.
func releaseLanguages(all []arr.Release) []string {
seen := map[string]bool{}
var out []string
for _, r := range all {
for _, l := range r.Attrs().Languages {
if !seen[l] {
seen[l] = true
out = append(out, l)
}
}
}
slices.Sort(out)
return out
}