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), } 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.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 · Esc back") 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) 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.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))) }