Files
silo-server/internal/metadata/tmdb/client.go
T
Silo Server Migration 3eddacfe88 Port collection templates with poster support and async syncs
- Add poster URLs through collection import and template apply flows
- Queue large template bundle syncs and preserve existing collection posters
- Update template bundle assets, docs, and limit handling
2026-05-23 12:31:08 -04:00

485 lines
14 KiB
Go

package tmdb
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"golang.org/x/time/rate"
)
const (
defaultBaseURL = "https://api.themoviedb.org/3"
defaultAPIKey = "4ef0d7355d9ffb5151e987764708ce96"
maxRetries = 3
maxResponseBody = 1 << 20 // 1 MB
maxCollectionPresetResults = 500
)
// Client is an HTTP client for the TMDB collection preset API surface.
type Client struct {
httpClient *http.Client
apiKey string
baseURL string
limiter *rate.Limiter
}
// NewClient creates a TMDB API client with the given API key and rate limit
// (requests per second). If apiKey is empty the built-in project key is used.
func NewClient(apiKey string, rateLimit int) *Client {
if apiKey == "" {
apiKey = defaultAPIKey
}
return &Client{
httpClient: &http.Client{Timeout: 30 * time.Second},
apiKey: apiKey,
baseURL: defaultBaseURL,
limiter: rate.NewLimiter(rate.Limit(rateLimit), rateLimit),
}
}
// SetBaseURL overrides the API base URL. Used for testing.
func (c *Client) SetBaseURL(url string) {
c.baseURL = url
}
// doGet executes a GET request against the TMDB API with rate limiting,
// exponential backoff on 5xx/429, and JSON decoding into dest.
func (c *Client) doGet(ctx context.Context, path string, dest any) error {
if err := c.limiter.Wait(ctx); err != nil {
return err
}
sep := "?"
if strings.Contains(path, "?") {
sep = "&"
}
reqURL := c.baseURL + path + sep + "api_key=" + url.QueryEscape(c.apiKey)
for attempt := 0; attempt <= maxRetries; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return fmt.Errorf("tmdb: create request: %w", err)
}
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("tmdb: request failed: %w", err)
}
if resp.StatusCode == http.StatusTooManyRequests {
resp.Body.Close()
if attempt < maxRetries {
backoff := retryAfterOrDefault(resp, attempt)
select {
case <-time.After(backoff):
case <-ctx.Done():
return ctx.Err()
}
continue
}
return fmt.Errorf("tmdb: rate limited after %d retries", maxRetries)
}
if resp.StatusCode >= 500 {
resp.Body.Close()
if attempt < maxRetries {
backoff := time.Duration(1<<attempt) * time.Second
select {
case <-time.After(backoff):
case <-ctx.Done():
return ctx.Err()
}
continue
}
return fmt.Errorf("tmdb: server error %d after %d retries", resp.StatusCode, maxRetries)
}
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
resp.Body.Close()
var apiErr apiError
if err := json.Unmarshal(body, &apiErr); err == nil && apiErr.StatusMessage != "" {
return fmt.Errorf("tmdb: HTTP %d: %s", resp.StatusCode, apiErr.StatusMessage)
}
return fmt.Errorf("tmdb: HTTP %d", resp.StatusCode)
}
decodeErr := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBody)).Decode(dest)
resp.Body.Close()
if decodeErr != nil {
return fmt.Errorf("tmdb: decode response: %w", decodeErr)
}
return nil
}
return fmt.Errorf("tmdb: max retries exceeded")
}
// retryAfterOrDefault parses the Retry-After header (seconds) or falls back
// to exponential backoff.
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
}
}
return time.Duration(1<<attempt) * time.Second
}
func normalizeCollectionPreset(preset, mediaType, timeWindow string) (string, string, string, string, error) {
switch preset {
case "trending":
switch mediaType {
case "all", "movie", "tv":
default:
return "", "", "", "", fmt.Errorf("tmdb: invalid media type for preset %q: %q", preset, mediaType)
}
switch timeWindow {
case "day", "week":
default:
return "", "", "", "", fmt.Errorf("tmdb: invalid time window for preset %q: %q", preset, timeWindow)
}
return preset, mediaType, timeWindow, fmt.Sprintf("/trending/%s/%s", mediaType, timeWindow), nil
case "popular", "top_rated":
switch mediaType {
case "movie":
return preset, mediaType, "", fmt.Sprintf("/movie/%s", preset), nil
case "tv":
return preset, mediaType, "", fmt.Sprintf("/tv/%s", preset), nil
default:
return "", "", "", "", fmt.Errorf("tmdb: invalid media type for preset %q: %q", preset, mediaType)
}
case "now_playing", "upcoming":
if mediaType != "movie" {
return "", "", "", "", fmt.Errorf("tmdb: preset %q requires media type %q", preset, "movie")
}
return preset, mediaType, "", fmt.Sprintf("/movie/%s", preset), nil
case "airing_today", "on_the_air":
if mediaType != "tv" {
return "", "", "", "", fmt.Errorf("tmdb: preset %q requires media type %q", preset, "tv")
}
return preset, mediaType, "", fmt.Sprintf("/tv/%s", preset), nil
default:
return "", "", "", "", fmt.Errorf("tmdb: invalid preset: %q", preset)
}
}
// GetCollectionPreset fetches a normalized preset collection from TMDB.
func (c *Client) GetCollectionPreset(ctx context.Context, preset, mediaType, timeWindow string, limit int) ([]CollectionResult, error) {
_, normalizedMediaType, _, basePath, err := normalizeCollectionPreset(preset, mediaType, timeWindow)
if err != nil {
return nil, err
}
if limit <= 0 {
limit = 20
}
if limit > maxCollectionPresetResults {
limit = maxCollectionPresetResults
}
results := make([]CollectionResult, 0, limit)
for page := 1; len(results) < limit; page++ {
path := fmt.Sprintf("%s?page=%d", basePath, page)
done := false
switch preset {
case "trending":
var resp paginatedResponse[TrendingResult]
if err := c.doGet(ctx, path, &resp); err != nil {
return nil, err
}
for _, item := range resp.Results {
title := item.Title
if title == "" {
title = item.Name
}
results = append(results, CollectionResult{
ID: item.ID,
MediaType: item.MediaType,
Title: title,
})
}
if page >= resp.TotalPages {
done = true
}
case "popular", "top_rated":
if normalizedMediaType == "tv" {
var resp paginatedResponse[TVResult]
if err := c.doGet(ctx, path, &resp); err != nil {
return nil, err
}
for _, item := range resp.Results {
results = append(results, CollectionResult{
ID: item.ID,
MediaType: "tv",
Title: item.Name,
})
}
if page >= resp.TotalPages {
done = true
}
break
}
var resp paginatedResponse[MovieResult]
if err := c.doGet(ctx, path, &resp); err != nil {
return nil, err
}
for _, item := range resp.Results {
results = append(results, CollectionResult{
ID: item.ID,
MediaType: "movie",
Title: item.Title,
})
}
if page >= resp.TotalPages {
done = true
}
case "now_playing", "upcoming":
var resp paginatedResponse[MovieResult]
if err := c.doGet(ctx, path, &resp); err != nil {
return nil, err
}
for _, item := range resp.Results {
results = append(results, CollectionResult{
ID: item.ID,
MediaType: "movie",
Title: item.Title,
})
}
if page >= resp.TotalPages {
done = true
}
case "airing_today", "on_the_air":
var resp paginatedResponse[TVResult]
if err := c.doGet(ctx, path, &resp); err != nil {
return nil, err
}
for _, item := range resp.Results {
results = append(results, CollectionResult{
ID: item.ID,
MediaType: "tv",
Title: item.Name,
})
}
if page >= resp.TotalPages {
done = true
}
default:
return nil, fmt.Errorf("tmdb: invalid preset: %q", preset)
}
if done {
break
}
}
if len(results) > limit {
results = results[:limit]
}
return results, nil
}
// Discover fetches results from TMDB's `/discover/{movie,tv}` endpoint with
// the supplied filters. The client paginates up to params.Limit, capped at
// maxCollectionPresetResults.
func (c *Client) Discover(ctx context.Context, mediaType string, params DiscoverParams) ([]CollectionResult, error) {
switch mediaType {
case "movie", "tv":
default:
return nil, fmt.Errorf("tmdb: invalid media type for discover: %q", mediaType)
}
if strings.TrimSpace(params.SortBy) == "" {
return nil, fmt.Errorf("tmdb: discover requires sort_by")
}
basePath := "/discover/" + mediaType
baseQuery := buildDiscoverQuery(mediaType, params)
limit := params.Limit
if limit <= 0 {
limit = 20
}
if limit > maxCollectionPresetResults {
limit = maxCollectionPresetResults
}
results := make([]CollectionResult, 0, limit)
for page := 1; len(results) < limit; page++ {
query := baseQuery + "&page=" + strconv.Itoa(page)
path := basePath + "?" + query
if mediaType == "tv" {
var resp paginatedResponse[TVResult]
if err := c.doGet(ctx, path, &resp); err != nil {
return nil, err
}
for _, item := range resp.Results {
results = append(results, CollectionResult{
ID: item.ID,
MediaType: "tv",
Title: item.Name,
})
}
if page >= resp.TotalPages || len(resp.Results) == 0 {
break
}
continue
}
var resp paginatedResponse[MovieResult]
if err := c.doGet(ctx, path, &resp); err != nil {
return nil, err
}
for _, item := range resp.Results {
results = append(results, CollectionResult{
ID: item.ID,
MediaType: "movie",
Title: item.Title,
})
}
if page >= resp.TotalPages || len(resp.Results) == 0 {
break
}
}
if len(results) > limit {
results = results[:limit]
}
return results, nil
}
// buildDiscoverQuery composes the TMDB discover query string (without the
// leading "?" and without page or api_key — doGet handles those).
func buildDiscoverQuery(mediaType string, params DiscoverParams) string {
values := url.Values{}
values.Set("sort_by", params.SortBy)
if genres := joinIntSlice(params.WithGenres, ","); genres != "" {
values.Set("with_genres", genres)
}
if without := joinIntSlice(params.WithoutGenres, ","); without != "" {
values.Set("without_genres", without)
}
if params.VoteCountGte > 0 {
values.Set("vote_count.gte", strconv.Itoa(params.VoteCountGte))
}
if params.VoteAverageGte > 0 {
values.Set("vote_average.gte", strconv.FormatFloat(params.VoteAverageGte, 'f', -1, 64))
}
if params.ReleaseDateGte != "" {
if mediaType == "tv" {
values.Set("first_air_date.gte", params.ReleaseDateGte)
} else {
values.Set("primary_release_date.gte", params.ReleaseDateGte)
}
}
if params.ReleaseDateLte != "" {
if mediaType == "tv" {
values.Set("first_air_date.lte", params.ReleaseDateLte)
} else {
values.Set("primary_release_date.lte", params.ReleaseDateLte)
}
}
if len(params.Certifications) > 0 {
values.Set("certification_country", "US")
values.Set("certification", strings.Join(params.Certifications, "|"))
}
if cert := strings.TrimSpace(params.CertificationLte); cert != "" {
values.Set("certification_country", "US")
values.Set("certification.lte", cert)
}
if params.WithRuntimeGte > 0 {
values.Set("with_runtime.gte", strconv.Itoa(params.WithRuntimeGte))
}
if params.WithRuntimeLte > 0 {
values.Set("with_runtime.lte", strconv.Itoa(params.WithRuntimeLte))
}
if lang := strings.TrimSpace(params.OriginalLanguage); lang != "" {
values.Set("with_original_language", lang)
}
return values.Encode()
}
func joinIntSlice(values []int, sep string) string {
if len(values) == 0 {
return ""
}
parts := make([]string, len(values))
for i, v := range values {
parts[i] = strconv.Itoa(v)
}
return strings.Join(parts, sep)
}
// GetCollection fetches a single TMDB collection (franchise/saga) and returns
// the curated ordered list of parts (all movies). The TMDB endpoint is
// `/collection/{id}`; results come back in TMDB's curated order, which the
// catalog sync preserves as the collection's display order.
//
// The id is the TMDB collection ID (e.g. 86311 for the Marvel Cinematic
// Universe). An id of 0 is rejected here — placeholder templates with
// collection_id=0 are surfaced as failures by the sync path, not silently
// turned into an empty fetch.
func (c *Client) GetCollection(ctx context.Context, id int) (*Collection, error) {
if id <= 0 {
return nil, fmt.Errorf("tmdb: collection id must be > 0 (got %d)", id)
}
var resp collectionResponse
if err := c.doGet(ctx, fmt.Sprintf("/collection/%d", id), &resp); err != nil {
return nil, err
}
parts := make([]CollectionPart, 0, len(resp.Parts))
for _, p := range resp.Parts {
mediaType := p.MediaType
if mediaType == "" {
// TMDB /collection/{id} only ever returns movies; older docs
// sometimes omit the field, so default explicitly rather than
// leaking an empty string into the resolver.
mediaType = "movie"
}
parts = append(parts, CollectionPart{
ID: p.ID,
MediaType: mediaType,
Title: p.Title,
ReleaseDate: p.ReleaseDate,
})
}
return &Collection{
ID: resp.ID,
Name: resp.Name,
Parts: parts,
}, nil
}
// GetExternalIDs fetches external IDs for a TMDB movie or TV entry.
func (c *Client) GetExternalIDs(ctx context.Context, mediaType string, id int) (*ExternalIDs, error) {
var path string
switch mediaType {
case "movie":
path = fmt.Sprintf("/movie/%d?append_to_response=external_ids", id)
case "tv":
path = fmt.Sprintf("/tv/%d?append_to_response=external_ids", id)
default:
return nil, fmt.Errorf("tmdb: invalid media type: %q", mediaType)
}
var resp externalIDsResponse
if err := c.doGet(ctx, path, &resp); err != nil {
return nil, err
}
return resp.ExternalIDs, nil
}