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:
+159
-9
@@ -137,14 +137,33 @@ func (c *Client) Configured() bool {
|
||||
|
||||
// Entries fetches up to limit entries of a list.
|
||||
func (c *Client) Entries(ctx context.Context, l List, limit int) ([]Entry, error) {
|
||||
data, err := c.get(ctx, l.path, url.Values{"page": {"1"}, "limit": {strconv.Itoa(limit)}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries, err := decode(data, l)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("trakt: decoding %s: %w", l.path, err)
|
||||
}
|
||||
if len(entries) > limit {
|
||||
entries = entries[:limit]
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// get sends a GET request to the API and returns the body of a successful answer.
|
||||
func (c *Client) get(ctx context.Context, path string, query url.Values) ([]byte, error) {
|
||||
c.mu.RLock()
|
||||
base, id := c.baseURL, c.clientID
|
||||
c.mu.RUnlock()
|
||||
if id == "" {
|
||||
return nil, ErrNotConfigured
|
||||
}
|
||||
q := url.Values{"page": {"1"}, "limit": {strconv.Itoa(limit)}}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+l.path+"?"+q.Encode(), nil)
|
||||
u := base + path
|
||||
if len(query) > 0 {
|
||||
u += "?" + query.Encode()
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -167,21 +186,152 @@ func (c *Client) Entries(ctx context.Context, l List, limit int) ([]Entry, error
|
||||
return nil, fmt.Errorf("trakt: the client ID was not accepted (HTTP %d)", resp.StatusCode)
|
||||
case resp.StatusCode == http.StatusTooManyRequests:
|
||||
return nil, errors.New("trakt: too many requests, try again in a few minutes")
|
||||
case resp.StatusCode == http.StatusNotFound:
|
||||
return nil, ErrNotFound
|
||||
case resp.StatusCode < 200 || resp.StatusCode > 299:
|
||||
return nil, fmt.Errorf("trakt: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
return io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
}
|
||||
|
||||
// ErrNotFound is returned when Trakt does not know a title.
|
||||
var ErrNotFound = errors.New("trakt: not found")
|
||||
|
||||
// Ref names a title by an id of another database, as Radarr and Sonarr have
|
||||
// them: TMDb for movies, TVDB for shows, IMDb for both.
|
||||
type Ref struct {
|
||||
Kind Kind
|
||||
TMDB int
|
||||
TVDB int
|
||||
IMDB string
|
||||
}
|
||||
|
||||
// Key identifies the title, e.g. for caching ("" when it has no id).
|
||||
func (r Ref) Key() string {
|
||||
k := "movie:"
|
||||
if r.Kind == Shows {
|
||||
k = "show:"
|
||||
}
|
||||
switch {
|
||||
case r.Kind == Movies && r.TMDB > 0:
|
||||
return k + "tmdb:" + strconv.Itoa(r.TMDB)
|
||||
case r.Kind == Shows && r.TVDB > 0:
|
||||
return k + "tvdb:" + strconv.Itoa(r.TVDB)
|
||||
case r.IMDB != "":
|
||||
return k + "imdb:" + r.IMDB
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Person is a member of the cast or crew.
|
||||
type Person struct {
|
||||
Name string
|
||||
Role string // the character played, or the job
|
||||
}
|
||||
|
||||
// People are the cast (in Trakt's order) and the directors (movies) or
|
||||
// creators (shows) of a title.
|
||||
type People struct {
|
||||
Cast []Person
|
||||
Leaders []Person // directors of a movie, creators of a show
|
||||
}
|
||||
|
||||
// People looks the title up on Trakt and fetches its cast and crew.
|
||||
func (c *Client) People(ctx context.Context, r Ref) (People, error) {
|
||||
id, err := c.traktID(ctx, r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return People{}, err
|
||||
}
|
||||
entries, err := decode(data, l)
|
||||
kind := "movies"
|
||||
if r.Kind == Shows {
|
||||
kind = "shows"
|
||||
}
|
||||
data, err := c.get(ctx, "/"+kind+"/"+strconv.Itoa(id)+"/people", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("trakt: decoding %s: %w", l.path, err)
|
||||
return People{}, err
|
||||
}
|
||||
if len(entries) > limit {
|
||||
entries = entries[:limit]
|
||||
var raw struct {
|
||||
Cast []struct {
|
||||
Character string `json:"character"`
|
||||
Characters []string `json:"characters"`
|
||||
Person struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"person"`
|
||||
} `json:"cast"`
|
||||
Crew map[string][]struct {
|
||||
Job string `json:"job"`
|
||||
Jobs []string `json:"jobs"`
|
||||
Person struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"person"`
|
||||
} `json:"crew"`
|
||||
}
|
||||
return entries, nil
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return People{}, fmt.Errorf("trakt: decoding people: %w", err)
|
||||
}
|
||||
var p People
|
||||
for _, m := range raw.Cast {
|
||||
role := m.Character
|
||||
if len(m.Characters) > 0 {
|
||||
role = strings.Join(m.Characters, " / ")
|
||||
}
|
||||
p.Cast = append(p.Cast, Person{Name: m.Person.Name, Role: role})
|
||||
}
|
||||
// Directors of a movie; for a show the "created by" credits.
|
||||
key, job := "directing", "Director"
|
||||
if r.Kind == Shows {
|
||||
key, job = "created by", ""
|
||||
}
|
||||
for _, m := range raw.Crew[key] {
|
||||
jobs := append([]string{m.Job}, m.Jobs...)
|
||||
if job == "" || contains(jobs, job) {
|
||||
p.Leaders = append(p.Leaders, Person{Name: m.Person.Name, Role: m.Job})
|
||||
}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// traktID finds Trakt's id of a title from its TMDb, TVDB or IMDb id.
|
||||
func (c *Client) traktID(ctx context.Context, r Ref) (int, error) {
|
||||
idType, id := "imdb", r.IMDB
|
||||
switch {
|
||||
case r.Kind == Movies && r.TMDB > 0:
|
||||
idType, id = "tmdb", strconv.Itoa(r.TMDB)
|
||||
case r.Kind == Shows && r.TVDB > 0:
|
||||
idType, id = "tvdb", strconv.Itoa(r.TVDB)
|
||||
case id == "":
|
||||
return 0, ErrNotFound
|
||||
}
|
||||
kind := "movie"
|
||||
if r.Kind == Shows {
|
||||
kind = "show"
|
||||
}
|
||||
data, err := c.get(ctx, "/search/"+idType+"/"+url.PathEscape(id), url.Values{"type": {kind}})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var results []map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &results); err != nil {
|
||||
return 0, fmt.Errorf("trakt: decoding search: %w", err)
|
||||
}
|
||||
for _, res := range results {
|
||||
var media struct {
|
||||
IDs IDs `json:"ids"`
|
||||
}
|
||||
if raw, ok := res[kind]; ok && json.Unmarshal(raw, &media) == nil && media.IDs.Trakt > 0 {
|
||||
return media.IDs.Trakt, nil
|
||||
}
|
||||
}
|
||||
return 0, ErrNotFound
|
||||
}
|
||||
|
||||
func contains(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// decode reads a list response: either bare titles, or items with the title
|
||||
|
||||
@@ -85,3 +85,55 @@ func TestCompact(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeople(t *testing.T) {
|
||||
var paths []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
paths = append(paths, r.URL.RequestURI())
|
||||
switch r.URL.Path {
|
||||
case "/search/tmdb/272":
|
||||
w.Write([]byte(`[{"type":"movie","score":1000,"movie":{"title":"Batman Begins","year":2005,"ids":{"trakt":1,"slug":"batman-begins-2005","tmdb":272}}}]`))
|
||||
case "/movies/1/people":
|
||||
w.Write([]byte(`{"cast":[{"character":"Bruce Wayne","characters":["Bruce Wayne","Batman"],"person":{"name":"Christian Bale","ids":{"trakt":1}}},
|
||||
{"character":"Alfred","characters":["Alfred"],"person":{"name":"Michael Caine","ids":{"trakt":2}}}],
|
||||
"crew":{"directing":[{"job":"Director","jobs":["Director"],"person":{"name":"Christopher Nolan"}},{"job":"Assistant Director","jobs":["Assistant Director"],"person":{"name":"Someone"}}],
|
||||
"writing":[{"job":"Screenplay","jobs":["Screenplay"],"person":{"name":"David S. Goyer"}}]}}`))
|
||||
case "/search/tvdb/81189":
|
||||
w.Write([]byte(`[{"type":"show","score":1000,"show":{"title":"Breaking Bad","year":2008,"ids":{"trakt":1388,"tvdb":81189}}}]`))
|
||||
case "/shows/1388/people":
|
||||
w.Write([]byte(`{"cast":[{"character":"Walter White","characters":["Walter White"],"episode_count":62,"person":{"name":"Bryan Cranston"}}],
|
||||
"crew":{"created by":[{"job":"Creator","jobs":["Creator"],"person":{"name":"Vince Gilligan"}}]}}`))
|
||||
case "/search/tmdb/5":
|
||||
w.Write([]byte(`[]`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := NewClient("id", "ua")
|
||||
c.SetBaseURL(srv.URL)
|
||||
|
||||
movie, err := c.People(context.Background(), Ref{Kind: Movies, TMDB: 272, IMDB: "tt0372784"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(movie.Cast) != 2 || movie.Cast[0].Name != "Christian Bale" || movie.Cast[0].Role != "Bruce Wayne / Batman" {
|
||||
t.Errorf("cast %+v", movie.Cast)
|
||||
}
|
||||
if len(movie.Leaders) != 1 || movie.Leaders[0].Name != "Christopher Nolan" {
|
||||
t.Errorf("directors %+v", movie.Leaders)
|
||||
}
|
||||
if paths[0] != "/search/tmdb/272?type=movie" {
|
||||
t.Errorf("lookup %q", paths[0])
|
||||
}
|
||||
show, err := c.People(context.Background(), Ref{Kind: Shows, TVDB: 81189})
|
||||
if err != nil || show.Cast[0].Name != "Bryan Cranston" || len(show.Leaders) != 1 || show.Leaders[0].Name != "Vince Gilligan" {
|
||||
t.Errorf("show %+v %v", show, err)
|
||||
}
|
||||
if _, err := c.People(context.Background(), Ref{Kind: Movies, TMDB: 5}); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("unknown title: %v", err)
|
||||
}
|
||||
if (Ref{Kind: Shows, TVDB: 81189}).Key() != "show:tvdb:81189" || (Ref{Kind: Movies}).Key() != "" {
|
||||
t.Error("keys")
|
||||
}
|
||||
}
|
||||
|
||||
+5
-3
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user