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
|
||||
|
||||
Reference in New Issue
Block a user