// 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) }