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
+159 -9
View File
@@ -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
+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")
}
}