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" "git.stinnesbeck.com/nils/arr-tui/internal/trakt" ) // 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 } // detailText is the text of a detail pane: a short head (title, facts, // state) and the plot below it. type detailText struct { head, plot string // people names the title for its cast, loaded from Trakt when it is shown // (zero: none). people trakt.Ref } // String joins both parts, for places that show them as one text. func (d detailText) String() string { if d.plot == "" { return d.head } return d.head + "\n" + d.plot } func movieRef(m radarr.Movie) trakt.Ref { return trakt.Ref{Kind: trakt.Movies, TMDB: m.TMDBID, IMDB: m.IMDBID} } func seriesRef(s sonarr.Series) trakt.Ref { return trakt.Ref{Kind: trakt.Shows, TVDB: s.TVDBID, TMDB: s.TMDBID, IMDB: s.IMDBID} } // peopleText renders the cast and directors (creators) of a title for the // details pane, at most maxCast actors. func peopleText(p trakt.People, kind trakt.Kind) string { const maxCast = 8 var b strings.Builder if len(p.Leaders) > 0 { label := "Director" if kind == trakt.Shows { label = "Created by" } else if len(p.Leaders) > 1 { label = "Directors" } names := make([]string, len(p.Leaders)) for i, l := range p.Leaders { names[i] = tview.Escape(l.Name) } fmt.Fprintf(&b, "[::b]%s[::-] %s\n", label, strings.Join(names, ", ")) } if len(p.Cast) > 0 { b.WriteString("[::b]Cast[::-]\n") for i, c := range p.Cast { if i == maxCast { fmt.Fprintf(&b, "[gray]and %d more[-]\n", len(p.Cast)-maxCast) break } b.WriteString(tview.Escape(c.Name)) if c.Role != "" { b.WriteString(" [gray]as " + tview.Escape(c.Role) + "[-]") } b.WriteByte('\n') } } return strings.TrimRight(b.String(), "\n") } // movieDetails renders the detail pane text for a movie. func movieDetails(m radarr.Movie) detailText { 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") } return detailText{head: b.String(), plot: tview.Escape(m.Overview), people: movieRef(m)} } // 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) detailText { 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) } } return detailText{head: b.String(), plot: tview.Escape(s.Overview), people: seriesRef(s)} } 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 }