Release listing and grab in the shared arr client, a full-screen release list (sort, hide rejected, rejection reasons, confirmation before grabbing rejected releases; Usenet only), and a season/episode picker for Sonarr. Overlays are now a stack so dialogs can open above the release list. Add a fake Newznab indexer to the dev setup. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
336 lines
8.6 KiB
Go
336 lines
8.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
|
|
}
|
|
|
|
// 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))
|
|
}
|
|
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)))
|
|
}
|