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:
@@ -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) }
|
||||
Reference in New Issue
Block a user