Trending, most watched and anticipated movies and series from Trakt

With an empty search box, Movies and Series show one of Trakt's public
lists: trending now, most watched this week, anticipated, popular, and
for movies the US box office. [ ] switch lists, r reloads. Each entry is
looked up in Radarr (tmdb:) or Sonarr (tvdb:), so it behaves like a
search result: details, poster, tick when in the library, add, releases.

Needs a Trakt client ID: new optional trakt.client_id setting (Config
view section "Trakt", ARR_TUI_TRAKT_CLIENT_ID), masked like the API keys
and checked by Test. ARR_TUI_TRAKT_URL points at another server for
development.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 23:05:35 +02:00
co-authored by Claude Opus 5.5
parent b71659b357
commit 4dba794b26
18 changed files with 764 additions and 40 deletions
+236
View File
@@ -0,0 +1,236 @@
// 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) }