Add queue listing and removal/blocklisting to the shared arr client. The Downloads view now fetches SABnzbd and both arr queues in parallel, shows status, progress and import problems, flags items needing attention in the summary, and supports removing or blocklisting items. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
226 lines
5.8 KiB
Go
226 lines
5.8 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
|
|
}
|