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
+2
View File
@@ -2,6 +2,8 @@
## Unreleased ## Unreleased
- **Cast in the details pane:** with a Trakt client ID, the details of a movie or series list its
director (or creators) and cast with their roles below the plot, in every view.
- **Enter adds** a title in Movies and Series; `a` no longer does (the status bar only ever named - **Enter adds** a title in Movies and Series; `a` no longer does (the status bar only ever named
Enter, and the hint for searching releases of a title not in the library now says Enter too). Enter, and the hint for searching releases of a title not in the library now says Enter too).
- **Trending, most watched and anticipated** movies and series from Trakt: with an empty search - **Trending, most watched and anticipated** movies and series from Trakt: with an empty search
+2
View File
@@ -12,6 +12,8 @@ A terminal UI to search for movies and series with [Radarr](https://radarr.video
lists from [Trakt](https://trakt.tv): trending now, most watched this week, anticipated, popular, lists from [Trakt](https://trakt.tv): trending now, most watched this week, anticipated, popular,
and the US box office for movies. `[` and `]` switch lists; titles already in your library are and the US box office for movies. `[` and `]` switch lists; titles already in your library are
ticked, the others can be added right there (Enter). Needs a free Trakt client ID (see Configure). ticked, the others can be added right there (Enter). Needs a free Trakt client ID (see Configure).
- **Cast**: with a Trakt client ID, the details pane also lists the director (or the creators of a
series) and the cast with their roles, below the plot, in every view.
- **Interactive search**: list the releases Radarr/Sonarr find (approved or rejected, with reasons), - **Interactive search**: list the releases Radarr/Sonarr find (approved or rejected, with reasons),
sort and filter them (by approval, quality, x265 / x264 / AV1, HDR, Dolby Vision, audio format, language), and grab one. Series: pick a season or a single episode first. sort and filter them (by approval, quality, x265 / x264 / AV1, HDR, Dolby Vision, audio format, language), and grab one. Series: pick a season or a single episode first.
- **Posters**: the detail panes show the poster at the top right; title, facts, path and plot - **Posters**: the detail panes show the poster at the top right; title, facts, path and plot
+1
View File
@@ -70,6 +70,7 @@ Notes:
- **Calendar view (F5):** a two-week window (starting today, moved a week at a time with `[`/`]`, `t` returns to today) with the library's movie releases and airing episodes, grouped by day. Radarr `GET /calendar` supplies the cinema, digital and physical release dates (taken as written: midnight UTC means that calendar day) and Sonarr `GET /calendar?includeSeries=true` the episodes with their air time (shown in local time). Unmonitored items are included with `u`. A movie appears once per release date in the window. Each entry shows type, title, episode or release kind and status (downloaded / missing / upcoming); the detail pane shows poster and plot; `Enter`/`i` starts the release search and `s` an automatic search. - **Calendar view (F5):** a two-week window (starting today, moved a week at a time with `[`/`]`, `t` returns to today) with the library's movie releases and airing episodes, grouped by day. Radarr `GET /calendar` supplies the cinema, digital and physical release dates (taken as written: midnight UTC means that calendar day) and Sonarr `GET /calendar?includeSeries=true` the episodes with their air time (shown in local time). Unmonitored items are included with `u`. A movie appears once per release date in the window. Each entry shows type, title, episode or release kind and status (downloaded / missing / upcoming); the detail pane shows poster and plot; `Enter`/`i` starts the release search and `s` an automatic search.
- **Download notifications:** a background poller (independent of the visible view) reads SABnzbd's history every 15 s and announces downloads that reach `Completed` or `Failed` as a popup and log entry; the first poll only records the current state. `ui.notify_downloads` (default true) and `ui.bell` (default false, rings the terminal bell) control it. Connection errors are not reported here, the indicators and the log already do that. - **Download notifications:** a background poller (independent of the visible view) reads SABnzbd's history every 15 s and announces downloads that reach `Completed` or `Failed` as a popup and log entry; the first poll only records the current state. `ui.notify_downloads` (default true) and `ui.bell` (default false, rings the terminal bell) control it. Connection errors are not reported here, the indicators and the log already do that.
- **Trakt lists:** while the search box of Movies or Series is empty, the list shows one of Trakt's public lists instead of search results: trending now (`/movies|shows/trending`, "N watching"), most watched this week (`/…/watched/weekly`, "N watched it"), anticipated (`/…/anticipated`, "on N lists"), popular (`/…/popular`), and for movies the US box office of last weekend (`/movies/boxoffice`, "$N last weekend"); 20 titles each (`page=1&limit=20`), with the headers `trakt-api-key` (the client ID, `trakt.client_id` or `ARR_TUI_TRAKT_CLIENT_ID`), `trakt-api-version: 2`, `Content-Type` and `User-Agent`. Every entry is looked up in Radarr (`tmdb:<id>`) or Sonarr (`tvdb:<id>`), at most four at a time, so it behaves like a search result (details, poster, tick when in the library, add, releases); entries without that id or unknown to the service are left out, in list order otherwise. `[` `]` switch lists (also from the empty search box), `r` reloads; a fetched list is shown again for 15 minutes without asking. Without a client ID the list says how to get one. Changing the client ID or a service in the Config view drops the fetched lists. `ARR_TUI_TRAKT_URL` points the client at another server (used by `dev/screenshots`). - **Trakt lists:** while the search box of Movies or Series is empty, the list shows one of Trakt's public lists instead of search results: trending now (`/movies|shows/trending`, "N watching"), most watched this week (`/…/watched/weekly`, "N watched it"), anticipated (`/…/anticipated`, "on N lists"), popular (`/…/popular`), and for movies the US box office of last weekend (`/movies/boxoffice`, "$N last weekend"); 20 titles each (`page=1&limit=20`), with the headers `trakt-api-key` (the client ID, `trakt.client_id` or `ARR_TUI_TRAKT_CLIENT_ID`), `trakt-api-version: 2`, `Content-Type` and `User-Agent`. Every entry is looked up in Radarr (`tmdb:<id>`) or Sonarr (`tvdb:<id>`), at most four at a time, so it behaves like a search result (details, poster, tick when in the library, add, releases); entries without that id or unknown to the service are left out, in list order otherwise. `[` `]` switch lists (also from the empty search box), `r` reloads; a fetched list is shown again for 15 minutes without asking. Without a client ID the list says how to get one. Changing the client ID or a service in the Config view drops the fetched lists. `ARR_TUI_TRAKT_URL` points the client at another server (used by `dev/screenshots`).
- **Cast:** with a Trakt client ID, every details pane (search results, Trakt lists, Library, Calendar, the series drill-down) shows the director(s) of a movie or the "created by" credits of a series and up to 8 cast members with their characters below the plot ("and N more" after that). The title is found on Trakt by its TMDb id (movies) or TVDB id (series), else IMDb id (`/search/<type>/<id>?type=movie|show`), then `/movies|shows/<trakt id>/people` is read. Casts are cached for the session (up to 500 titles; a title Trakt does not know counts as having none; failures are retried after two minutes) and, like posters, only loaded once the selection has rested for 120 ms, so a held arrow key does not spend Trakt's allowance (500 requests per five minutes).
- **Config view (F6):** one list with a field for every setting of the config file, one below the other under the headings Radarr, Sonarr, SABnzbd, Trakt and Interface (a single scrolling form; ↑↓ passes over the headings and ↓ after the last field reaches the Save / Revert buttons, so no Tab is needed; Tab jumps between the fields and the buttons). The `Test` button is part of the list, right below the SABnzbd settings; `Save` and `Revert` are centered in a bar below the list. When the config file does not exist, it is created empty (`config.Create`: directory `0700`, file `0600`) and the program opens on this view. A service with neither address nor key is "not configured": allowed by validation (half-filled ones are not), its client answers every call with `ErrNotConfigured` without a request, its indicator stays grey (logged once as information, not as unreachable) and its lists say "not configured (Config view, F6)" instead of an error. The fields start from the settings in use; API keys are masked (a checkbox shows them). The default quality profile and root folder of Radarr and Sonarr are dropdowns filled from the server (`/qualityprofile`, `/rootfolder`) with the address and key currently in the fields: when the view opens, on Revert, and 700 ms after the address or key was last changed; answers for values that have changed since are dropped, a failure is reported once in the result box. "(none)" comes first; a configured value the server does not list stays as an option, marked "(not on the server)", so opening the view never changes a setting. `Test` builds temporary clients from the typed values and reports each service. `Save` validates like startup does (URLs, non-empty keys, refresh 1–3600 s), then writes the file from the file's own contents plus the edits (`config.LoadFile`), so values that only come from `ARR_TUI_*` environment variables are left as the file has them and are shown greyed out; the write is atomic (temp file + rename) and keeps the file's permissions (`0600` when new); defaults such as `covers: true` stay out of the file unless it already named them. Everything then applies without a restart: the Radarr/Sonarr/SABnzbd clients are re-pointed (`Reconfigure`, guarded by a mutex), the connection indicators, add-form defaults and options are refreshed, the library and calendar reload, the Downloads interval, notifications, bell and poster style are switched live. - **Config view (F6):** one list with a field for every setting of the config file, one below the other under the headings Radarr, Sonarr, SABnzbd, Trakt and Interface (a single scrolling form; ↑↓ passes over the headings and ↓ after the last field reaches the Save / Revert buttons, so no Tab is needed; Tab jumps between the fields and the buttons). The `Test` button is part of the list, right below the SABnzbd settings; `Save` and `Revert` are centered in a bar below the list. When the config file does not exist, it is created empty (`config.Create`: directory `0700`, file `0600`) and the program opens on this view. A service with neither address nor key is "not configured": allowed by validation (half-filled ones are not), its client answers every call with `ErrNotConfigured` without a request, its indicator stays grey (logged once as information, not as unreachable) and its lists say "not configured (Config view, F6)" instead of an error. The fields start from the settings in use; API keys are masked (a checkbox shows them). The default quality profile and root folder of Radarr and Sonarr are dropdowns filled from the server (`/qualityprofile`, `/rootfolder`) with the address and key currently in the fields: when the view opens, on Revert, and 700 ms after the address or key was last changed; answers for values that have changed since are dropped, a failure is reported once in the result box. "(none)" comes first; a configured value the server does not list stays as an option, marked "(not on the server)", so opening the view never changes a setting. `Test` builds temporary clients from the typed values and reports each service. `Save` validates like startup does (URLs, non-empty keys, refresh 1–3600 s), then writes the file from the file's own contents plus the edits (`config.LoadFile`), so values that only come from `ARR_TUI_*` environment variables are left as the file has them and are shown greyed out; the write is atomic (temp file + rename) and keeps the file's permissions (`0600` when new); defaults such as `covers: true` stay out of the file unless it already named them. Everything then applies without a restart: the Radarr/Sonarr/SABnzbd clients are re-pointed (`Reconfigure`, guarded by a mutex), the connection indicators, add-form defaults and options are refreshed, the library and calendar reload, the Downloads interval, notifications, bell and poster style are switched live.
- **Notifications and log (F7):** feedback about actions (added, deleted, grabbed, failures) appears as popups in the bottom right that fade away (errors stay longer). Everything is also kept in the Log view; only connection changes of the background polling are logged, not every failed poll. - **Notifications and log (F7):** feedback about actions (added, deleted, grabbed, failures) appears as popups in the bottom right that fade away (errors stay longer). Everything is also kept in the Log view; only connection changes of the background polling are logged, not every failed poll.
+159 -9
View File
@@ -137,14 +137,33 @@ func (c *Client) Configured() bool {
// Entries fetches up to limit entries of a list. // Entries fetches up to limit entries of a list.
func (c *Client) Entries(ctx context.Context, l List, limit int) ([]Entry, error) { 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() c.mu.RLock()
base, id := c.baseURL, c.clientID base, id := c.baseURL, c.clientID
c.mu.RUnlock() c.mu.RUnlock()
if id == "" { if id == "" {
return nil, ErrNotConfigured return nil, ErrNotConfigured
} }
q := url.Values{"page": {"1"}, "limit": {strconv.Itoa(limit)}} u := base + path
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+l.path+"?"+q.Encode(), nil) if len(query) > 0 {
u += "?" + query.Encode()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil { if err != nil {
return nil, err 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) return nil, fmt.Errorf("trakt: the client ID was not accepted (HTTP %d)", resp.StatusCode)
case resp.StatusCode == http.StatusTooManyRequests: case resp.StatusCode == http.StatusTooManyRequests:
return nil, errors.New("trakt: too many requests, try again in a few minutes") 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: case resp.StatusCode < 200 || resp.StatusCode > 299:
return nil, fmt.Errorf("trakt: HTTP %d", resp.StatusCode) 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 { 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 { if err != nil {
return nil, fmt.Errorf("trakt: decoding %s: %w", l.path, err) return People{}, err
} }
if len(entries) > limit { var raw struct {
entries = entries[:limit] 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 // decode reads a list response: either bare titles, or items with the title
+52
View File
@@ -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
View File
@@ -32,9 +32,10 @@ type view struct {
// App is the top-level arr-tui application. // App is the top-level arr-tui application.
type App struct { type App struct {
app *tview.Application app *tview.Application
theme theme // the colors in use theme theme // the colors in use
trakt *trakt.Client // trending, most watched and anticipated lists 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. // The Movies and Series views, to refresh their Trakt lists.
searches []*searchView searches []*searchView
themed *themedScreen // draws with them; nil until Run 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) time.Duration(cfg.UI.RefreshSeconds)*time.Second)
a.downloads = downloads a.downloads = downloads
a.trakt = trakt.NewClient(cfg.Trakt.ClientID, "arr-tui") a.trakt = trakt.NewClient(cfg.Trakt.ClientID, "arr-tui")
a.people = newPeopleCache()
if u := os.Getenv("ARR_TUI_TRAKT_URL"); u != "" { if u := os.Getenv("ARR_TUI_TRAKT_URL"); u != "" {
a.trakt.SetBaseURL(u) // another Trakt API, for development (dev/screenshots) 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/arr"
"git.stinnesbeck.com/nils/arr-tui/internal/radarr" "git.stinnesbeck.com/nils/arr-tui/internal/radarr"
"git.stinnesbeck.com/nils/arr-tui/internal/sonarr" "git.stinnesbeck.com/nils/arr-tui/internal/sonarr"
"git.stinnesbeck.com/nils/arr-tui/internal/trakt"
) )
const ( const (
@@ -134,7 +135,7 @@ func episodeEntry(e sonarr.Episode, from, to time.Time, cover coverRef, now time
return calEntry{ return calEntry{
day: day, at: at, title: series, label: tview.Escape(series), sub: sub, day: day, at: at, title: series, label: tview.Escape(series), sub: sub,
have: e.HasFile, monitored: e.Monitored, missing: e.Monitored && !e.HasFile && aired, 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, episodeID: e.ID,
}, true }, 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 { if old.Trakt.ClientID != c.Trakt.ClientID {
a.trakt.Reconfigure(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 { if old.Trakt.ClientID != c.Trakt.ClientID || old.Radarr.Service != c.Radarr.Service || old.Sonarr.Service != c.Sonarr.Service {
for _, v := range a.searches { 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 lastShow time.Time // when the shown title last changed
settle *time.Timer // shows the poster once the selection rests 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. // What the text was last wrapped for.
laidOut struct { laidOut struct {
width, narrow, rows int width, narrow, rows int
@@ -65,6 +71,7 @@ func newDetailPane(app *App) *detailPane {
func (d *detailPane) Show(text detailText, ref coverRef) { func (d *detailPane) Show(text detailText, ref coverRef) {
d.detail, d.lastRef = text, ref d.detail, d.lastRef = text, ref
d.laidOut.valid = false d.laidOut.valid = false
d.showPeople(text.people)
if !d.app.covers || ref.fetch == nil { if !d.app.covers || ref.fetch == nil {
d.want, d.hasCover = "", false d.want, d.hasCover = "", false
d.cover.setImage(nil) 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 { 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.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) d.Flex.Draw(screen)
+59
View File
@@ -9,6 +9,8 @@ import (
"github.com/gdamore/tcell/v2" "github.com/gdamore/tcell/v2"
"github.com/rivo/tview" "github.com/rivo/tview"
"git.stinnesbeck.com/nils/arr-tui/internal/trakt"
) )
func rowText(screen tcell.SimulationScreen, y, x0, x1 int) string { 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") 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) 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. // 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/arr"
"git.stinnesbeck.com/nils/arr-tui/internal/radarr" "git.stinnesbeck.com/nils/arr-tui/internal/radarr"
"git.stinnesbeck.com/nils/arr-tui/internal/sonarr" "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. // 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. // state) and the plot below it.
type detailText struct { type detailText struct {
head, plot string 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. // 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 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. // movieDetails renders the detail pane text for a movie.
func movieDetails(m radarr.Movie) detailText { func movieDetails(m radarr.Movie) detailText {
var b strings.Builder var b strings.Builder
@@ -73,7 +120,7 @@ func movieDetails(m radarr.Movie) detailText {
case m.InLibrary(): case m.InLibrary():
b.WriteString("[yellow]In library, not downloaded[-]\n") 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. // 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) 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 { 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()
}