Add interactive release search (M6)
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>
This commit is contained in:
@@ -0,0 +1,335 @@
|
||||
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)))
|
||||
}
|
||||
Reference in New Issue
Block a user