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>
234 lines
5.9 KiB
Go
234 lines
5.9 KiB
Go
package ui
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/rivo/tview"
|
|
|
|
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
|
|
"git.stinnesbeck.com/nils/arr-tui/internal/radarr"
|
|
"git.stinnesbeck.com/nils/arr-tui/internal/sonarr"
|
|
)
|
|
|
|
// movieLabel is the one-line list entry for a movie.
|
|
func movieLabel(m radarr.Movie) string {
|
|
label := tview.Escape(m.Title)
|
|
if m.Year > 0 {
|
|
label += fmt.Sprintf(" (%d)", m.Year)
|
|
}
|
|
if m.InLibrary() {
|
|
label += " [green]✓[-]"
|
|
}
|
|
return label
|
|
}
|
|
|
|
// movieDetails renders the detail pane text for a movie.
|
|
func movieDetails(m radarr.Movie) string {
|
|
var b strings.Builder
|
|
title := tview.Escape(m.Title)
|
|
if m.Year > 0 {
|
|
title += fmt.Sprintf(" (%d)", m.Year)
|
|
}
|
|
fmt.Fprintf(&b, "[::b]%s[::-]\n", title)
|
|
|
|
var facts []string
|
|
if r := m.Rating(); r > 0 {
|
|
facts = append(facts, fmt.Sprintf("★ %.1f", r))
|
|
}
|
|
if m.Runtime > 0 {
|
|
facts = append(facts, fmt.Sprintf("%dh%02dm", m.Runtime/60, m.Runtime%60))
|
|
}
|
|
if len(m.Genres) > 0 {
|
|
facts = append(facts, tview.Escape(strings.Join(m.Genres, ", ")))
|
|
}
|
|
if len(facts) > 0 {
|
|
b.WriteString(strings.Join(facts, " · ") + "\n")
|
|
}
|
|
if m.Studio != "" {
|
|
fmt.Fprintf(&b, "Studio: %s\n", tview.Escape(m.Studio))
|
|
}
|
|
if m.Status != "" {
|
|
fmt.Fprintf(&b, "Status: %s\n", tview.Escape(m.Status))
|
|
}
|
|
switch {
|
|
case m.HasFile:
|
|
b.WriteString("[green]In library, downloaded[-]\n")
|
|
case m.InLibrary():
|
|
b.WriteString("[yellow]In library, not downloaded[-]\n")
|
|
}
|
|
if m.Overview != "" {
|
|
b.WriteString("\n" + tview.Escape(m.Overview) + "\n")
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// profileIndex returns the index of the profile named name, or 0.
|
|
func profileIndex(profiles []arr.QualityProfile, name string) int {
|
|
for i, p := range profiles {
|
|
if strings.EqualFold(p.Name, name) {
|
|
return i
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// folderIndex returns the index of the root folder at path, or 0.
|
|
func folderIndex(folders []arr.RootFolder, path string) int {
|
|
for i, f := range folders {
|
|
if strings.TrimRight(f.Path, "/") == strings.TrimRight(path, "/") {
|
|
return i
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func itoa(n int) string { return strconv.Itoa(n) }
|
|
|
|
// seriesLabel is the one-line list entry for a series.
|
|
func seriesLabel(s sonarr.Series) string {
|
|
label := tview.Escape(s.Title)
|
|
if s.Year > 0 {
|
|
label += fmt.Sprintf(" (%d)", s.Year)
|
|
}
|
|
if s.InLibrary() {
|
|
label += " [green]✓[-]"
|
|
}
|
|
return label
|
|
}
|
|
|
|
// seriesDetails renders the detail pane text for a series.
|
|
func seriesDetails(s sonarr.Series) string {
|
|
var b strings.Builder
|
|
title := tview.Escape(s.Title)
|
|
if s.Year > 0 {
|
|
title += fmt.Sprintf(" (%d)", s.Year)
|
|
}
|
|
fmt.Fprintf(&b, "[::b]%s[::-]\n", title)
|
|
|
|
var facts []string
|
|
if s.Ratings.Value > 0 {
|
|
facts = append(facts, fmt.Sprintf("★ %.1f", s.Ratings.Value))
|
|
}
|
|
if n := s.SeasonCount(); n > 0 {
|
|
facts = append(facts, fmt.Sprintf("%d season%s", n, plural(n)))
|
|
}
|
|
if s.Runtime > 0 {
|
|
facts = append(facts, fmt.Sprintf("%d min", s.Runtime))
|
|
}
|
|
if len(s.Genres) > 0 {
|
|
facts = append(facts, tview.Escape(strings.Join(s.Genres, ", ")))
|
|
}
|
|
if len(facts) > 0 {
|
|
b.WriteString(strings.Join(facts, " · ") + "\n")
|
|
}
|
|
if s.Network != "" {
|
|
fmt.Fprintf(&b, "Network: %s\n", tview.Escape(s.Network))
|
|
}
|
|
if s.Status != "" {
|
|
fmt.Fprintf(&b, "Status: %s\n", tview.Escape(s.Status))
|
|
}
|
|
if s.InLibrary() {
|
|
st := s.Statistics
|
|
if st.EpisodeCount > 0 && st.EpisodeFileCount >= st.EpisodeCount {
|
|
b.WriteString("[green]In library, all episodes downloaded[-]\n")
|
|
} else {
|
|
fmt.Fprintf(&b, "[yellow]In library, %d/%d episodes downloaded[-]\n", st.EpisodeFileCount, st.EpisodeCount)
|
|
}
|
|
}
|
|
if s.Overview != "" {
|
|
b.WriteString("\n" + tview.Escape(s.Overview) + "\n")
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func plural(n int) string {
|
|
if n == 1 {
|
|
return ""
|
|
}
|
|
return "s"
|
|
}
|
|
|
|
// formatSpeed renders a transfer rate given in KiB/s.
|
|
func formatSpeed(kbPerSec float64) string {
|
|
switch {
|
|
case kbPerSec >= 1024:
|
|
return fmt.Sprintf("%.1f MB/s", kbPerSec/1024)
|
|
case kbPerSec >= 1:
|
|
return fmt.Sprintf("%.0f KB/s", kbPerSec)
|
|
}
|
|
return "0 KB/s"
|
|
}
|
|
|
|
// progressBar renders pct (0-100) as a bar of the given width, followed by the percentage.
|
|
func progressBar(pct float64, width int) string {
|
|
pct = min(max(pct, 0), 100)
|
|
filled := int(pct / 100 * float64(width))
|
|
return strings.Repeat("█", filled) + strings.Repeat("░", width-filled) + fmt.Sprintf(" %3.0f%%", pct)
|
|
}
|
|
|
|
// formatAge renders a unix timestamp relative to now ("5m ago", "3d ago").
|
|
func formatAge(unix int64, now time.Time) string {
|
|
if unix <= 0 {
|
|
return ""
|
|
}
|
|
d := now.Sub(time.Unix(unix, 0))
|
|
switch {
|
|
case d < time.Minute:
|
|
return "just now"
|
|
case d < time.Hour:
|
|
return fmt.Sprintf("%dm ago", int(d.Minutes()))
|
|
case d < 48*time.Hour:
|
|
return fmt.Sprintf("%dh ago", int(d.Hours()))
|
|
}
|
|
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
|
|
}
|
|
|
|
// truncate shortens s to at most n runes, ending in "…" if it was cut.
|
|
func truncate(s string, n int) string {
|
|
r := []rune(s)
|
|
if len(r) <= n {
|
|
return s
|
|
}
|
|
return string(r[:n-1]) + "…"
|
|
}
|
|
|
|
// arrStatusText renders the state of a Radarr/Sonarr queue item, colored by severity.
|
|
func arrStatusText(it arr.QueueItem) string {
|
|
label := map[string]string{
|
|
"downloading": "Downloading",
|
|
"importPending": "Import pending",
|
|
"importBlocked": "Import blocked",
|
|
"importing": "Importing",
|
|
"imported": "Imported",
|
|
"failedPending": "Failed (pending)",
|
|
"failed": "Failed",
|
|
"ignored": "Ignored",
|
|
}[it.TrackedDownloadState]
|
|
if label == "" {
|
|
label = it.Status
|
|
if label != "" {
|
|
label = strings.ToUpper(label[:1]) + label[1:]
|
|
}
|
|
}
|
|
label = tview.Escape(label)
|
|
switch {
|
|
case it.TrackedDownloadStatus == "error" || it.TrackedDownloadState == "failed" ||
|
|
it.TrackedDownloadState == "failedPending" || it.TrackedDownloadState == "importBlocked":
|
|
return "[red]" + label + "[-]"
|
|
case it.TrackedDownloadStatus == "warning":
|
|
return "[yellow]" + label + "[-]"
|
|
}
|
|
return label
|
|
}
|
|
|
|
// movieTitleYear renders "Title (Year)".
|
|
func movieTitleYear(m radarr.Movie) string {
|
|
if m.Year > 0 {
|
|
return fmt.Sprintf("%s (%d)", m.Title, m.Year)
|
|
}
|
|
return m.Title
|
|
}
|