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>
This commit is contained in:
2026-10-23 23:31:33 +02:00
co-authored by Claude Opus 5.5
parent 8775c27aab
commit 12e825cff0
13 changed files with 468 additions and 17 deletions
+5 -3
View File
@@ -32,9 +32,10 @@ type view struct {
// App is the top-level arr-tui application.
type App struct {
app *tview.Application
theme theme // the colors in use
trakt *trakt.Client // trending, most watched and anticipated lists
app *tview.Application
theme theme // the colors in use
trakt *trakt.Client // trending, most watched and anticipated lists, casts
people *peopleCache // casts loaded from Trakt
// The Movies and Series views, to refresh their Trakt lists.
searches []*searchView
themed *themedScreen // draws with them; nil until Run
@@ -175,6 +176,7 @@ func New(cfg *config.Config) *App {
time.Duration(cfg.UI.RefreshSeconds)*time.Second)
a.downloads = downloads
a.trakt = trakt.NewClient(cfg.Trakt.ClientID, "arr-tui")
a.people = newPeopleCache()
if u := os.Getenv("ARR_TUI_TRAKT_URL"); u != "" {
a.trakt.SetBaseURL(u) // another Trakt API, for development (dev/screenshots)
}
+10 -1
View File
@@ -15,6 +15,7 @@ import (
"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"
)
const (
@@ -134,7 +135,7 @@ func episodeEntry(e sonarr.Episode, from, to time.Time, cover coverRef, now time
return calEntry{
day: day, at: at, title: series, label: tview.Escape(series), sub: sub,
have: e.HasFile, monitored: e.Monitored, missing: e.Monitored && !e.HasFile && aired,
details: detailText{head: head, plot: tview.Escape(overview)}, cover: cover,
details: detailText{head: head, plot: tview.Escape(overview), people: episodeSeriesRef(e)}, cover: cover,
episodeID: e.ID,
}, true
}
@@ -467,3 +468,11 @@ func (v *calendarView) search() {
})
}()
}
// episodeSeriesRef names the series of a calendar episode for its cast.
func episodeSeriesRef(e sonarr.Episode) trakt.Ref {
if e.Series == nil {
return trakt.Ref{}
}
return seriesRef(*e.Series)
}
+4
View File
@@ -33,6 +33,10 @@ func (a *App) applyConfig(c *config.Config) {
}
if old.Trakt.ClientID != c.Trakt.ClientID {
a.trakt.Reconfigure(c.Trakt.ClientID)
a.people = newPeopleCache()
for _, p := range a.panes {
p.castKey = "" // ask again the next time a title is shown
}
}
if old.Trakt.ClientID != c.Trakt.ClientID || old.Radarr.Service != c.Radarr.Service || old.Sonarr.Service != c.Sonarr.Service {
for _, v := range a.searches {
+12 -1
View File
@@ -40,6 +40,12 @@ type detailPane struct {
lastShow time.Time // when the shown title last changed
settle *time.Timer // shows the poster once the selection rests
// The cast from Trakt, below the plot (see people.go).
castKey string // the title it belongs to
cast string // rendered; "" while loading or when there is none
castShown time.Time
castSettle *time.Timer
// What the text was last wrapped for.
laidOut struct {
width, narrow, rows int
@@ -65,6 +71,7 @@ func newDetailPane(app *App) *detailPane {
func (d *detailPane) Show(text detailText, ref coverRef) {
d.detail, d.lastRef = text, ref
d.laidOut.valid = false
d.showPeople(text.people)
if !d.app.covers || ref.fetch == nil {
d.want, d.hasCover = "", false
d.cover.setImage(nil)
@@ -137,7 +144,11 @@ func (d *detailPane) Draw(screen tcell.Screen) {
}
if !d.laidOut.valid || d.laidOut.width != w || d.laidOut.narrow != narrow || d.laidOut.rows != rows {
d.laidOut.width, d.laidOut.narrow, d.laidOut.rows, d.laidOut.valid = w, narrow, rows, true
d.text.SetText(strings.Join(flowWrap(d.detail.String(), narrow, w, rows), "\n"))
text := d.detail.String()
if d.cast != "" {
text += "\n\n" + d.cast
}
d.text.SetText(strings.Join(flowWrap(text, narrow, w, rows), "\n"))
}
d.Flex.Draw(screen)
+59
View File
@@ -9,6 +9,8 @@ import (
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
"git.stinnesbeck.com/nils/arr-tui/internal/trakt"
)
func rowText(screen tcell.SimulationScreen, y, x0, x1 int) string {
@@ -137,3 +139,60 @@ func TestPosterWaitsWhileTheSelectionMoves(t *testing.T) {
t.Error("a selection at rest should show its poster")
}
}
func TestCastBelowThePlot(t *testing.T) {
a := newTestApp(t)
d := newDetailPane(a)
dune := trakt.Ref{Kind: trakt.Movies, TMDB: 438631}
heat := trakt.Ref{Kind: trakt.Movies, TMDB: 949}
a.people.done[dune.Key()] = trakt.People{
Leaders: []trakt.Person{{Name: "Denis Villeneuve", Role: "Director"}},
Cast: []trakt.Person{{Name: "Timothée Chalamet", Role: "Paul Atreides"}, {Name: "Rebecca Ferguson", Role: "Lady Jessica"}},
}
a.people.done[heat.Key()] = trakt.People{}
// Without a Trakt client ID nothing is asked or shown.
d.Show(detailText{head: "Dune", people: dune}, coverRef{})
if d.cast != "" {
t.Fatalf("cast without Trakt: %q", d.cast)
}
a.trakt.Reconfigure("id")
d.castKey, d.castShown = "", time.Time{}
d.Show(detailText{head: "Dune", plot: "Spice.", people: dune}, coverRef{})
if !strings.Contains(d.cast, "Director[::-] Denis Villeneuve") || !strings.Contains(d.cast, "Timothée Chalamet [gray]as Paul Atreides") {
t.Errorf("cast %q", d.cast)
}
screen := tcell.NewSimulationScreen("")
if err := screen.Init(); err != nil {
t.Fatal(err)
}
defer screen.Fini()
screen.SetSize(60, 20)
d.SetRect(0, 0, 60, 20)
d.Draw(screen)
if got := d.text.GetText(true); !strings.Contains(got, "Spice.\n\nDirector Denis Villeneuve\nCast\nTimothée Chalamet as Paul Atreides") {
t.Errorf("pane text %q", got)
}
// A quick move to the next title waits with its cast until the selection rests.
d.Show(detailText{head: "Heat", people: heat}, coverRef{})
if d.cast != "" || d.castSettle == nil {
t.Errorf("moving: cast %q", d.cast)
}
d.castSettle.Stop()
}
func TestPeopleText(t *testing.T) {
var cast []trakt.Person
for i := 0; i < 10; i++ {
cast = append(cast, trakt.Person{Name: "Actor " + string(rune('A'+i))})
}
got := peopleText(trakt.People{Cast: cast, Leaders: []trakt.Person{{Name: "Vince [Gilligan]"}}}, trakt.Shows)
if !strings.HasPrefix(got, "[::b]Created by[::-] Vince [Gilligan[]") || !strings.HasSuffix(got, "[gray]and 2 more[-]") || strings.Contains(got, "Actor I") {
t.Errorf("text %q", got)
}
if peopleText(trakt.People{}, trakt.Movies) != "" {
t.Error("no people, no text")
}
}
+1 -1
View File
@@ -294,7 +294,7 @@ func seriesRowDetail(s sonarr.Series, ep *sonarr.Episode, season int, sum season
plot = tview.Escape(ep.Overview)
}
}
return detailText{head: head, plot: plot}
return detailText{head: head, plot: plot, people: seriesRef(s)}
}
// enter expands or collapses a season, or opens the release search for an episode.
+49 -2
View File
@@ -11,6 +11,7 @@ import (
"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.
@@ -29,6 +30,9 @@ func movieLabel(m radarr.Movie) string {
// 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.
@@ -39,6 +43,49 @@ func (d detailText) String() string {
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
@@ -73,7 +120,7 @@ func movieDetails(m radarr.Movie) detailText {
case m.InLibrary():
b.WriteString("[yellow]In library, not downloaded[-]\n")
}
return detailText{head: b.String(), plot: tview.Escape(m.Overview)}
return detailText{head: b.String(), plot: tview.Escape(m.Overview), people: movieRef(m)}
}
// profileIndex returns the index of the profile named name, or 0.
@@ -149,7 +196,7 @@ func seriesDetails(s sonarr.Series) detailText {
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)}
return detailText{head: b.String(), plot: tview.Escape(s.Overview), people: seriesRef(s)}
}
func plural(n int) string {
+112
View File
@@ -0,0 +1,112 @@
package ui
import (
"context"
"errors"
"time"
"git.stinnesbeck.com/nils/arr-tui/internal/trakt"
)
const (
maxPeopleCache = 500
peopleRetryWait = 2 * time.Minute // a failed lookup is not repeated sooner
)
// peopleCache holds the casts loaded from Trakt and the lookups in flight.
// UI goroutine only.
type peopleCache struct {
done map[string]trakt.People
failed map[string]time.Time
waiting map[string][]func(trakt.People, error)
}
func newPeopleCache() *peopleCache {
return &peopleCache{done: map[string]trakt.People{}, failed: map[string]time.Time{}, waiting: map[string][]func(trakt.People, error){}}
}
// loadPeople delivers the cast of a title to done on the UI goroutine,
// asking Trakt in the background when it is not cached. A title Trakt does
// not know is remembered as having no cast.
func (a *App) loadPeople(r trakt.Ref, done func(trakt.People, error)) {
c, key := a.people, r.Key()
if p, ok := c.done[key]; ok {
done(p, nil)
return
}
if t, ok := c.failed[key]; ok && time.Since(t) < peopleRetryWait {
done(trakt.People{}, errors.New("cast not available"))
return
}
if _, inflight := c.waiting[key]; inflight {
c.waiting[key] = append(c.waiting[key], done)
return
}
c.waiting[key] = []func(trakt.People, error){done}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
p, err := a.trakt.People(ctx, r)
if errors.Is(err, trakt.ErrNotFound) {
p, err = trakt.People{}, nil
}
a.post(func() {
if a.people != c {
return // dropped meanwhile (the client ID changed)
}
waiters := c.waiting[key]
delete(c.waiting, key)
if err != nil {
c.failed[key] = time.Now()
} else {
if len(c.done) >= maxPeopleCache {
c.done = map[string]trakt.People{}
}
c.done[key] = p
}
for _, w := range waiters {
w(p, err)
}
})
}()
}
// showPeople loads the cast of the title the pane shows, once the selection
// rests (like the poster: holding an arrow key must not send a request per
// step, Trakt allows 500 per five minutes).
func (d *detailPane) showPeople(r trakt.Ref) {
key := r.Key()
if key == d.castKey {
return // the same title again (a refresh): keep what is shown
}
if d.castSettle != nil {
d.castSettle.Stop()
}
d.castKey, d.cast = key, ""
if key == "" || !d.app.trakt.Configured() {
return
}
now := time.Now()
moving := now.Sub(d.castShown) < posterSettle
d.castShown = now
load := func() {
d.app.loadPeople(r, func(p trakt.People, err error) {
if d.castKey != key || err != nil {
return
}
d.cast = peopleText(p, r.Kind)
d.laidOut.valid = false
})
}
if moving {
d.castSettle = time.AfterFunc(posterSettle, func() {
d.app.post(func() {
if d.castKey == key {
load()
}
})
})
return
}
load()
}