Files
nilsandClaude Opus 5.5 12e825cff0 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>
2026-10-23 23:31:33 +02:00

387 lines
11 KiB
Go

// Package trakt reads the public lists of Trakt (trakt.tv): trending, most
// watched, anticipated, popular and box office movies and shows. Only a client
// ID is needed; nothing is written and no user account is involved.
package trakt
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
)
// DefaultBaseURL is the Trakt API.
const DefaultBaseURL = "https://api.trakt.tv"
// ErrNotConfigured is returned without a request while no client ID is set.
var ErrNotConfigured = errors.New("no Trakt client ID: create an API app on trakt.tv and enter its client ID in the Config view (F6)")
// Kind says whether a list holds movies or shows.
type Kind int
const (
Movies Kind = iota
Shows
)
// List is one of Trakt's public lists.
type List struct {
Name string // shown to the user, e.g. "Trending now"
path string // e.g. "/movies/trending"
count string // the field holding the list's number, "" for none
// Note formats the number for display, e.g. "1.2k watching".
note func(n int64) string
kind Kind
}
// Kind returns whether the list holds movies or shows.
func (l List) Kind() Kind { return l.kind }
// Note describes an entry's number in this list, or "" when it has none.
func (l List) Note(n int64) string {
if l.note == nil || n <= 0 {
return ""
}
return l.note(n)
}
func watching(n int64) string { return Compact(n) + " watching" }
func watchers(n int64) string { return Compact(n) + " watched it" }
func lists(n int64) string { return "on " + Compact(n) + " lists" }
func revenue(n int64) string { return "$" + Compact(n) + " last weekend" }
func movieList(name, path, count string, note func(int64) string) List {
return List{Name: name, path: path, count: count, note: note, kind: Movies}
}
func showList(name, path, count string, note func(int64) string) List {
return List{Name: name, path: path, count: count, note: note, kind: Shows}
}
// MovieLists and ShowLists are the lists arr-tui offers, in order.
var (
MovieLists = []List{
movieList("Trending now", "/movies/trending", "watchers", watching),
movieList("Most watched this week", "/movies/watched/weekly", "watcher_count", watchers),
movieList("Anticipated", "/movies/anticipated", "list_count", lists),
movieList("Popular", "/movies/popular", "", nil),
movieList("Box office (US, last weekend)", "/movies/boxoffice", "revenue", revenue),
}
ShowLists = []List{
showList("Trending now", "/shows/trending", "watchers", watching),
showList("Most watched this week", "/shows/watched/weekly", "watcher_count", watchers),
showList("Anticipated", "/shows/anticipated", "list_count", lists),
showList("Popular", "/shows/popular", "", nil),
}
)
// IDs are the identifiers Trakt knows for a title. TVDB is set for shows only.
type IDs struct {
Trakt int `json:"trakt"`
Slug string `json:"slug"`
IMDB string `json:"imdb"`
TMDB int `json:"tmdb"`
TVDB int `json:"tvdb"`
}
// Entry is one title on a list, with the list's number for it (watchers,
// lists it is on, revenue; 0 for lists without one).
type Entry struct {
Title string `json:"title"`
Year int `json:"year"`
IDs IDs `json:"ids"`
Count int64 `json:"-"`
}
// Client talks to the Trakt API.
type Client struct {
mu sync.RWMutex
baseURL string
clientID string
userAgent string
http *http.Client
}
// NewClient returns a client with the given client ID (may be empty: then
// every call returns ErrNotConfigured).
func NewClient(clientID, userAgent string) *Client {
return &Client{baseURL: DefaultBaseURL, clientID: strings.TrimSpace(clientID), userAgent: userAgent,
http: &http.Client{Timeout: 20 * time.Second}}
}
// Reconfigure sets another client ID.
func (c *Client) Reconfigure(clientID string) {
c.mu.Lock()
defer c.mu.Unlock()
c.clientID = strings.TrimSpace(clientID)
}
// SetBaseURL points the client at another server (tests).
func (c *Client) SetBaseURL(u string) {
c.mu.Lock()
defer c.mu.Unlock()
c.baseURL = strings.TrimRight(u, "/")
}
// Configured reports whether a client ID is set.
func (c *Client) Configured() bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.clientID != ""
}
// 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
}
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
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", c.userAgent)
req.Header.Set("trakt-api-key", id)
req.Header.Set("trakt-api-version", "2")
resp, err := c.http.Do(req)
if err != nil {
var ue *url.Error
if errors.As(err, &ue) {
return nil, fmt.Errorf("trakt: %w", ue.Err)
}
return nil, err
}
defer resp.Body.Close()
switch {
case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden:
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)
}
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 People{}, err
}
kind := "movies"
if r.Kind == Shows {
kind = "shows"
}
data, err := c.get(ctx, "/"+kind+"/"+strconv.Itoa(id)+"/people", nil)
if err != nil {
return People{}, err
}
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"`
}
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
// under "movie"/"show" next to the list's number.
func decode(data []byte, l List) ([]Entry, error) {
var items []map[string]json.RawMessage
if err := json.Unmarshal(data, &items); err != nil {
return nil, err
}
key := "movie"
if l.kind == Shows {
key = "show"
}
out := make([]Entry, 0, len(items))
for _, item := range items {
var e Entry
raw, wrapped := item[key]
if !wrapped {
// A bare title: the item is the title itself.
b, _ := json.Marshal(item)
raw = b
}
if err := json.Unmarshal(raw, &e); err != nil {
return nil, err
}
if l.count != "" {
if n, ok := item[l.count]; ok {
_ = json.Unmarshal(n, &e.Count)
}
}
out = append(out, e)
}
return out, nil
}
// Compact writes a number briefly: 950, 1.2k, 34k, 1.5M.
func Compact(n int64) string {
switch {
case n < 1000:
return strconv.FormatInt(n, 10)
case n < 10_000:
return trimZero(fmt.Sprintf("%.1fk", float64(n)/1000))
case n < 1_000_000:
return fmt.Sprintf("%dk", n/1000)
case n < 10_000_000:
return trimZero(fmt.Sprintf("%.1fM", float64(n)/1_000_000))
default:
return fmt.Sprintf("%dM", n/1_000_000)
}
}
func trimZero(s string) string { return strings.Replace(s, ".0", "", 1) }