// 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) { 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) 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 < 200 || resp.StatusCode > 299: return nil, fmt.Errorf("trakt: HTTP %d", resp.StatusCode) } data, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) 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 } // 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) }