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>
237 lines
6.9 KiB
Go
237 lines
6.9 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) {
|
|
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) }
|