Files
silo-server/internal/metadata/trakt/client.go
193a2905b2 feat(collections): back collections with user-authored Trakt lists (#286)
* feat(collections): back collections with user-authored Trakt lists

Collections could sync only Trakt's built-in trending/popular/recommended
feeds; a server admin could not populate a collection from a specific
user's Trakt list (e.g. a curated 'Saw in timeline order' list) (#214).

- trakt.Client.GetUserList fetches /users/{user}/lists/{slug}/items in
  list order, mixing movies and shows and skipping non-title entries.
- New 'trakt_list' collection source mode: catalog.ParseTraktListURL
  accepts a trakt.tv list URL (or bare user/slug), and
  syncTraktListCollection reuses the preset pipeline's matching/ordering
  via an extracted completeTraktEntrySync helper. Public lists need no
  access token.
- Trakt import handler accepts list_url as an alternative to preset;
  the admin collection editor's Trakt form gains a Source toggle
  (discovery feed vs user list) with a list-URL input.

Additive-only: new source mode + optional request field; preset path
unchanged.

Fixes #214

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(collections): round-trip trakt_list through the edit form, unlock mixed libraries, validate list host

Three review fixes for user-list-backed collections:

- The admin edit form now detects mode "trakt_list", shows an editable
  list URL (mirroring the create form) and saves the source back as
  trakt_list with list_url preserved — previously any edit silently
  rewrote the collection into a trakt_preset Trending Movies feed.
- Library eligibility in list mode is mixed (movies + shows) instead of
  inheriting the hidden media-type default of movie, since Trakt lists
  mix both and entries match by their own type.
- ParseTraktListURL only accepts trakt.tv / www.trakt.tv hosts, so a
  list-shaped URL on another domain fails fast with the format error
  instead of a confusing later sync failure.

source_config now carries list_url alongside the legacy url key
(additive); sync reads list_url, then url, then source_url.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-07-05 02:08:19 -04:00

337 lines
9.2 KiB
Go

package trakt
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"golang.org/x/time/rate"
)
const (
defaultBaseURL = "https://api.trakt.tv"
maxRetries = 3
maxResponseBody = 2 << 20
maxCollectionPresetResults = 500
defaultCollectionPageLimit = 20
defaultCollectionRateLimit = 5
traktAPIVersion = "2"
)
// Client is an HTTP client for Trakt collection/discovery feeds.
type Client struct {
httpClient *http.Client
clientID string
baseURL string
limiter *rate.Limiter
}
// NewClient creates a Trakt client. clientID is required by Trakt for API calls.
func NewClient(clientID string, rateLimit int) *Client {
if rateLimit <= 0 {
rateLimit = defaultCollectionRateLimit
}
return &Client{
httpClient: &http.Client{Timeout: 20 * time.Second},
clientID: strings.TrimSpace(clientID),
baseURL: defaultBaseURL,
limiter: rate.NewLimiter(rate.Limit(rateLimit), rateLimit),
}
}
// SetBaseURL overrides the API base URL. Used by tests.
func (c *Client) SetBaseURL(raw string) {
c.baseURL = strings.TrimRight(raw, "/")
}
// GetCollectionPreset fetches a normalized Trakt discovery feed.
func (c *Client) GetCollectionPreset(ctx context.Context, preset, mediaType string, limit int, accessToken string) ([]CollectionEntry, error) {
path, err := collectionPresetPath(preset, mediaType)
if err != nil {
return nil, err
}
if preset == "recommended" && strings.TrimSpace(accessToken) == "" {
return nil, errors.New("trakt: recommended preset requires access token")
}
if limit <= 0 {
limit = defaultCollectionPageLimit
}
if limit > maxCollectionPresetResults {
limit = maxCollectionPresetResults
}
results := make([]CollectionEntry, 0, limit)
for page := 1; len(results) < limit; page++ {
pageLimit := limit - len(results)
if pageLimit > defaultCollectionPageLimit {
pageLimit = defaultCollectionPageLimit
}
reqPath := fmt.Sprintf("%s?page=%d&limit=%d", path, page, pageLimit)
var pageEntries []CollectionEntry
switch preset {
case "trending":
pageEntries, err = c.getTrending(ctx, reqPath, mediaType, accessToken)
case "popular", "recommended":
pageEntries, err = c.getMediaList(ctx, reqPath, mediaType, accessToken)
default:
err = fmt.Errorf("trakt: invalid preset %q", preset)
}
if err != nil {
return nil, err
}
for i := range pageEntries {
pageEntries[i].Rank = len(results) + i + 1
}
results = append(results, pageEntries...)
if len(pageEntries) < pageLimit {
break
}
}
if len(results) > limit {
results = results[:limit]
}
return results, nil
}
func collectionPresetPath(preset, mediaType string) (string, error) {
if mediaType != "movie" && mediaType != "tv" {
return "", fmt.Errorf("trakt: media_type must be movie or tv")
}
switch preset {
case "trending", "popular":
if mediaType == "movie" {
return "/movies/" + preset, nil
}
return "/shows/" + preset, nil
case "recommended":
if mediaType == "movie" {
return "/recommendations/movies", nil
}
return "/recommendations/shows", nil
default:
return "", fmt.Errorf("trakt: preset must be trending, popular, or recommended")
}
}
// GetUserList fetches the items of a user-authored Trakt list
// (/users/{user}/lists/{list}/items), preserving list order. Lists mix
// movies and shows; unknown item types (people, episodes, seasons) are
// skipped. Public lists need only the app client id; accessToken is passed
// through for the owner's private lists.
func (c *Client) GetUserList(ctx context.Context, user, list string, limit int, accessToken string) ([]CollectionEntry, error) {
user = strings.TrimSpace(user)
list = strings.TrimSpace(list)
if user == "" || list == "" {
return nil, errors.New("trakt: user list requires user and list slug")
}
if limit <= 0 {
limit = defaultCollectionPageLimit
}
if limit > maxCollectionPresetResults {
limit = maxCollectionPresetResults
}
basePath := fmt.Sprintf("/users/%s/lists/%s/items", url.PathEscape(user), url.PathEscape(list))
results := make([]CollectionEntry, 0, limit)
for page := 1; len(results) < limit; page++ {
pageLimit := limit - len(results)
if pageLimit > defaultCollectionPageLimit {
pageLimit = defaultCollectionPageLimit
}
var resp []struct {
Rank int `json:"rank"`
Type string `json:"type"`
Movie *traktMedia `json:"movie"`
Show *traktMedia `json:"show"`
}
reqPath := fmt.Sprintf("%s?page=%d&limit=%d", basePath, page, pageLimit)
if err := c.doGet(ctx, reqPath, accessToken, &resp); err != nil {
return nil, err
}
if len(resp) == 0 {
break
}
for _, row := range resp {
var entry CollectionEntry
switch {
case row.Type == "movie" && row.Movie != nil:
entry = row.Movie.entry("movie")
case row.Type == "show" && row.Show != nil:
entry = row.Show.entry("tv")
default:
continue
}
entry.Rank = row.Rank
results = append(results, entry)
if len(results) >= limit {
break
}
}
if len(resp) < pageLimit {
break
}
}
return results, nil
}
func (c *Client) getTrending(ctx context.Context, path, mediaType, accessToken string) ([]CollectionEntry, error) {
if mediaType == "movie" {
var resp []struct {
Movie traktMedia `json:"movie"`
}
if err := c.doGet(ctx, path, accessToken, &resp); err != nil {
return nil, err
}
entries := make([]CollectionEntry, 0, len(resp))
for _, row := range resp {
entries = append(entries, row.Movie.entry("movie"))
}
return entries, nil
}
var resp []struct {
Show traktMedia `json:"show"`
}
if err := c.doGet(ctx, path, accessToken, &resp); err != nil {
return nil, err
}
entries := make([]CollectionEntry, 0, len(resp))
for _, row := range resp {
entries = append(entries, row.Show.entry("tv"))
}
return entries, nil
}
func (c *Client) getMediaList(ctx context.Context, path, mediaType, accessToken string) ([]CollectionEntry, error) {
var resp []traktMedia
if err := c.doGet(ctx, path, accessToken, &resp); err != nil {
return nil, err
}
entries := make([]CollectionEntry, 0, len(resp))
for _, row := range resp {
entries = append(entries, row.entry(mediaType))
}
return entries, nil
}
func (c *Client) doGet(ctx context.Context, path string, accessToken string, dest any) error {
if strings.TrimSpace(c.clientID) == "" {
return errors.New("trakt: client id is required")
}
if err := c.limiter.Wait(ctx); err != nil {
return err
}
reqURL := c.baseURL + path
for attempt := 0; attempt <= maxRetries; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return fmt.Errorf("trakt: create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("trakt-api-version", traktAPIVersion)
req.Header.Set("trakt-api-key", c.clientID)
if strings.TrimSpace(accessToken) != "" {
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(accessToken))
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("trakt: request failed: %w", err)
}
if resp.StatusCode == http.StatusTooManyRequests {
resp.Body.Close()
if attempt < maxRetries {
if err := sleepContext(ctx, retryAfterOrDefault(resp, attempt)); err != nil {
return err
}
continue
}
return fmt.Errorf("trakt: rate limited after %d retries", maxRetries)
}
if resp.StatusCode >= 500 {
resp.Body.Close()
if attempt < maxRetries {
if err := sleepContext(ctx, time.Duration(1<<attempt)*time.Second); err != nil {
return err
}
continue
}
return fmt.Errorf("trakt: server error %d after %d retries", resp.StatusCode, maxRetries)
}
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
resp.Body.Close()
return fmt.Errorf("trakt: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
decodeErr := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBody)).Decode(dest)
resp.Body.Close()
if decodeErr != nil {
return fmt.Errorf("trakt: decode response: %w", decodeErr)
}
return nil
}
return fmt.Errorf("trakt: max retries exceeded")
}
func sleepContext(ctx context.Context, d time.Duration) error {
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-timer.C:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func retryAfterOrDefault(resp *http.Response, attempt int) time.Duration {
if val := resp.Header.Get("Retry-After"); val != "" {
if secs, err := strconv.Atoi(val); err == nil && secs > 0 {
return time.Duration(secs) * time.Second
}
if when, err := http.ParseTime(val); err == nil {
if d := time.Until(when); d > 0 {
return d
}
}
}
return time.Duration(1<<attempt) * time.Second
}
type traktMedia struct {
Title string `json:"title"`
Year int `json:"year"`
IDs traktIDs `json:"ids"`
}
type traktIDs struct {
Trakt int `json:"trakt"`
TMDB int `json:"tmdb"`
TVDB int `json:"tvdb"`
IMDb string `json:"imdb"`
}
func (m traktMedia) entry(mediaType string) CollectionEntry {
return CollectionEntry{
TraktID: m.IDs.Trakt,
TMDBID: m.IDs.TMDB,
TVDBID: m.IDs.TVDB,
IMDbID: strings.TrimSpace(m.IDs.IMDb),
MediaType: mediaType,
Title: m.Title,
Year: m.Year,
}
}