Files
arr-tui/internal/ui/format.go
T
nilsandClaude Opus 5.5 12e825cff0 Details pane: director and cast from Trakt
With a Trakt client ID, the details of a movie or series show the
director(s) or creators and up to 8 cast members with their roles below
the plot, in every view. The title is found on Trakt by its TMDb/TVDB
(else IMDb) id, then its people are read. Casts are cached and, like
posters, only loaded once the selection rests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:31:33 +02:00

289 lines
7.6 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"
"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
}