Files
silo-server/internal/metadata/tmdb/client.go
T
Silo Server Migration cc09e14154 feat(tmdb): cache discover and external ID responses
- Add TTL caches with singleflight for DiscoverSection, DiscoverPage, and GetExternalIDs to reduce upstream TMDB calls
- Reuse CastCarousel with a fullBleed variant on RequestDetail and drop the bespoke RequestCastRow
2026-05-25 02:02:33 -04:00

1136 lines
33 KiB
Go

package tmdb
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
"github.com/Silo-Server/silo-server/internal/cache"
"golang.org/x/sync/singleflight"
"golang.org/x/time/rate"
)
const (
defaultBaseURL = "https://api.themoviedb.org/3"
projectAPIKey = "4ef0d7355d9ffb5151e987764708ce96"
maxRetries = 3
maxResponseBody = 1 << 20 // 1 MB
maxCollectionPresetResults = 500
defaultResponseCacheTTL = 2 * time.Hour
)
// 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
discoverSectionCache *cache.TTLCache[*MediaPage]
discoverPageCache *cache.TTLCache[*MediaPage]
externalIDCache *cache.TTLCache[*ExternalIDs]
cacheGroup singleflight.Group
responseCacheTTL time.Duration
}
// NewClient creates a TMDB API client with the given API key and rate limit
// (requests per second). If apiKey is empty, Silo's public project API key is
// used.
func NewClient(apiKey string, rateLimit int) *Client {
apiKey = strings.TrimSpace(apiKey)
if apiKey == "" {
apiKey = projectAPIKey
}
return &Client{
httpClient: &http.Client{Timeout: 30 * time.Second},
apiKey: apiKey,
baseURL: defaultBaseURL,
limiter: rate.NewLimiter(rate.Limit(rateLimit), rateLimit),
discoverSectionCache: cache.NewTTLCache[*MediaPage](),
discoverPageCache: cache.NewTTLCache[*MediaPage](),
externalIDCache: cache.NewTTLCache[*ExternalIDs](),
responseCacheTTL: defaultResponseCacheTTL,
}
}
// SetBaseURL overrides the API base URL. Used for testing.
func (c *Client) SetBaseURL(url string) {
c.baseURL = url
}
// Close releases background cache sweepers owned by the client.
func (c *Client) Close() {
if c == nil {
return
}
if c.discoverSectionCache != nil {
c.discoverSectionCache.Close()
}
if c.discoverPageCache != nil {
c.discoverPageCache.Close()
}
if c.externalIDCache != nil {
c.externalIDCache.Close()
}
}
// 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
}
// SearchMedia searches TMDB directly for movies, TV series, or both. mediaType
// accepts Silo-facing "movie", "series", or "all" values, plus TMDB-facing
// "tv" for callers already working at the provider boundary.
func (c *Client) SearchMedia(ctx context.Context, mediaType, query string, page int) (*MediaPage, error) {
query = strings.TrimSpace(query)
if query == "" {
return nil, fmt.Errorf("tmdb: search query is required")
}
if page <= 0 {
page = 1
}
switch mediaType {
case "movie":
values := url.Values{}
values.Set("query", query)
values.Set("include_adult", "false")
values.Set("page", strconv.Itoa(page))
var resp paginatedResponse[mediaMovieResponse]
if err := c.doGet(ctx, "/search/movie?"+values.Encode(), &resp); err != nil {
return nil, err
}
return normalizeMoviePage(resp), nil
case "series", "tv":
values := url.Values{}
values.Set("query", query)
values.Set("include_adult", "false")
values.Set("page", strconv.Itoa(page))
var resp paginatedResponse[mediaTVResponse]
if err := c.doGet(ctx, "/search/tv?"+values.Encode(), &resp); err != nil {
return nil, err
}
return normalizeTVPage(resp), nil
case "all":
values := url.Values{}
values.Set("query", query)
values.Set("include_adult", "false")
values.Set("page", strconv.Itoa(page))
var resp paginatedResponse[mediaMultiSearchResponse]
if err := c.doGet(ctx, "/search/multi?"+values.Encode(), &resp); err != nil {
return nil, err
}
return normalizeMultiSearchPage(resp), nil
default:
return nil, fmt.Errorf("tmdb: invalid media type for search: %q", mediaType)
}
}
// DiscoverSection fetches one of Silo's request-discovery sections directly
// from TMDB. It intentionally does not go through collection sync or
// collection templates.
func (c *Client) DiscoverSection(ctx context.Context, section string, page int) (*MediaPage, error) {
if page <= 0 {
page = 1
}
cacheKey := "discover_section:" + section + ":" + strconv.Itoa(page)
if c.discoverSectionCache != nil {
if cached, ok := c.discoverSectionCache.Get(cacheKey); ok {
return cloneMediaPage(cached), nil
}
}
value, err, _ := c.cacheGroup.Do(cacheKey, func() (any, error) {
if c.discoverSectionCache != nil {
if cached, ok := c.discoverSectionCache.Get(cacheKey); ok {
return cached, nil
}
}
page, err := c.fetchDiscoverSection(ctx, section, page)
if err != nil {
return nil, err
}
cached := cloneMediaPage(page)
if c.discoverSectionCache != nil && c.responseCacheTTL > 0 {
c.discoverSectionCache.Set(cacheKey, cached, c.responseCacheTTL)
}
return cached, nil
})
if err != nil {
return nil, err
}
pageValue, ok := value.(*MediaPage)
if !ok {
return nil, fmt.Errorf("tmdb: invalid cached discovery section response")
}
return cloneMediaPage(pageValue), nil
}
func (c *Client) fetchDiscoverSection(ctx context.Context, section string, page int) (*MediaPage, error) {
values := url.Values{}
values.Set("page", strconv.Itoa(page))
switch section {
case "trending_movies":
var resp paginatedResponse[mediaMovieResponse]
if err := c.doGet(ctx, "/trending/movie/week?"+values.Encode(), &resp); err != nil {
return nil, err
}
return normalizeMoviePage(resp), nil
case "trending_series":
var resp paginatedResponse[mediaTVResponse]
if err := c.doGet(ctx, "/trending/tv/week?"+values.Encode(), &resp); err != nil {
return nil, err
}
return normalizeTVPage(resp), nil
case "popular_movies":
var resp paginatedResponse[mediaMovieResponse]
if err := c.doGet(ctx, "/movie/popular?"+values.Encode(), &resp); err != nil {
return nil, err
}
return normalizeMoviePage(resp), nil
case "popular_series":
var resp paginatedResponse[mediaTVResponse]
if err := c.doGet(ctx, "/tv/popular?"+values.Encode(), &resp); err != nil {
return nil, err
}
return normalizeTVPage(resp), nil
case "upcoming_movies":
var resp paginatedResponse[mediaMovieResponse]
if err := c.doGet(ctx, "/movie/upcoming?"+values.Encode(), &resp); err != nil {
return nil, err
}
return normalizeMoviePage(resp), nil
case "on_air_series":
var resp paginatedResponse[mediaTVResponse]
if err := c.doGet(ctx, "/tv/on_the_air?"+values.Encode(), &resp); err != nil {
return nil, err
}
return normalizeTVPage(resp), nil
default:
return nil, fmt.Errorf("tmdb: invalid discovery section: %q", section)
}
}
func cloneMediaPage(page *MediaPage) *MediaPage {
if page == nil {
return nil
}
cloned := *page
if page.Results != nil {
cloned.Results = append([]MediaResult(nil), page.Results...)
}
return &cloned
}
func normalizeMoviePage(resp paginatedResponse[mediaMovieResponse]) *MediaPage {
page := &MediaPage{
Page: resp.Page,
TotalPages: resp.TotalPages,
TotalResults: resp.TotalResults,
Results: make([]MediaResult, 0, len(resp.Results)),
}
for _, item := range resp.Results {
page.Results = append(page.Results, MediaResult{
ID: item.ID,
MediaType: "movie",
Title: item.Title,
Overview: item.Overview,
PosterPath: item.PosterPath,
BackdropPath: item.BackdropPath,
ReleaseDate: item.ReleaseDate,
Year: releaseYear(item.ReleaseDate),
Popularity: item.Popularity,
VoteAverage: item.VoteAverage,
})
}
return page
}
func normalizeTVPage(resp paginatedResponse[mediaTVResponse]) *MediaPage {
page := &MediaPage{
Page: resp.Page,
TotalPages: resp.TotalPages,
TotalResults: resp.TotalResults,
Results: make([]MediaResult, 0, len(resp.Results)),
}
for _, item := range resp.Results {
page.Results = append(page.Results, MediaResult{
ID: item.ID,
MediaType: "series",
Title: item.Name,
Overview: item.Overview,
PosterPath: item.PosterPath,
BackdropPath: item.BackdropPath,
ReleaseDate: item.FirstAirDate,
Year: releaseYear(item.FirstAirDate),
Popularity: item.Popularity,
VoteAverage: item.VoteAverage,
})
}
return page
}
func normalizeMultiSearchPage(resp paginatedResponse[mediaMultiSearchResponse]) *MediaPage {
page := &MediaPage{
Page: resp.Page,
TotalPages: resp.TotalPages,
TotalResults: resp.TotalResults,
Results: make([]MediaResult, 0, len(resp.Results)),
}
for _, item := range resp.Results {
switch item.MediaType {
case "movie":
page.Results = append(page.Results, MediaResult{
ID: item.ID,
MediaType: "movie",
Title: item.Title,
Overview: item.Overview,
PosterPath: item.PosterPath,
BackdropPath: item.BackdropPath,
ReleaseDate: item.ReleaseDate,
Year: releaseYear(item.ReleaseDate),
Popularity: item.Popularity,
VoteAverage: item.VoteAverage,
})
case "tv":
page.Results = append(page.Results, MediaResult{
ID: item.ID,
MediaType: "series",
Title: item.Name,
Overview: item.Overview,
PosterPath: item.PosterPath,
BackdropPath: item.BackdropPath,
ReleaseDate: item.FirstAirDate,
Year: releaseYear(item.FirstAirDate),
Popularity: item.Popularity,
VoteAverage: item.VoteAverage,
})
}
}
return page
}
func releaseYear(value string) int {
if len(value) < 4 {
return 0
}
year, err := strconv.Atoi(value[:4])
if err != nil {
return 0
}
return year
}
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
}
// DiscoverPage fetches a single page from TMDB's /discover/{movie,tv} endpoint
// and returns the full MediaPage shape (with posters, overviews, etc.) so the
// request system can enrich it with availability and request state. Unlike
// Discover (which is intended for collection templates and returns just IDs
// and titles), DiscoverPage exposes single-page semantics: callers control
// pagination explicitly.
func (c *Client) DiscoverPage(ctx context.Context, mediaType string, params DiscoverParams, page int) (*MediaPage, 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")
}
if page <= 0 {
page = 1
}
query := buildDiscoverQuery(mediaType, params) + "&page=" + strconv.Itoa(page)
path := "/discover/" + mediaType + "?" + query
cacheKey := "discover_page:" + path
if c.discoverPageCache != nil {
if cached, ok := c.discoverPageCache.Get(cacheKey); ok {
return cloneMediaPage(cached), nil
}
}
value, err, _ := c.cacheGroup.Do(cacheKey, func() (any, error) {
if c.discoverPageCache != nil {
if cached, ok := c.discoverPageCache.Get(cacheKey); ok {
return cached, nil
}
}
page, err := c.fetchDiscoverPage(ctx, mediaType, path)
if err != nil {
return nil, err
}
cached := cloneMediaPage(page)
if c.discoverPageCache != nil && c.responseCacheTTL > 0 {
c.discoverPageCache.Set(cacheKey, cached, c.responseCacheTTL)
}
return cached, nil
})
if err != nil {
return nil, err
}
pageValue, ok := value.(*MediaPage)
if !ok {
return nil, fmt.Errorf("tmdb: invalid cached discover page response")
}
return cloneMediaPage(pageValue), nil
}
func (c *Client) fetchDiscoverPage(ctx context.Context, mediaType, path string) (*MediaPage, error) {
if mediaType == "tv" {
var resp paginatedResponse[mediaTVResponse]
if err := c.doGet(ctx, path, &resp); err != nil {
return nil, err
}
return normalizeTVPage(resp), nil
}
var resp paginatedResponse[mediaMovieResponse]
if err := c.doGet(ctx, path, &resp); err != nil {
return nil, err
}
return normalizeMoviePage(resp), 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 companies := joinIntSlice(params.WithCompanies, ","); companies != "" {
values.Set("with_companies", companies)
}
if networks := joinIntSlice(params.WithNetworks, ","); networks != "" {
values.Set("with_networks", networks)
}
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
}
// GetMediaDetail fetches a single TMDB movie or series with credits, external
// IDs, recommendations, and the appropriate certification feed, returning a
// normalized MediaDetail. mediaType accepts Silo-facing "movie" or "series".
//
// Cast is sorted by TMDB billing order and capped at 24 entries to keep the
// payload bounded.
func (c *Client) GetMediaDetail(ctx context.Context, mediaType string, id int) (*MediaDetail, error) {
if id <= 0 {
return nil, fmt.Errorf("tmdb: media id must be > 0 (got %d)", id)
}
switch mediaType {
case "movie":
path := fmt.Sprintf("/movie/%d?append_to_response=credits,external_ids,recommendations,release_dates", id)
var resp movieDetailResponse
if err := c.doGet(ctx, path, &resp); err != nil {
return nil, err
}
return normalizeMovieDetail(&resp), nil
case "series", "tv":
path := fmt.Sprintf("/tv/%d?append_to_response=credits,external_ids,recommendations,content_ratings", id)
var resp tvDetailResponse
if err := c.doGet(ctx, path, &resp); err != nil {
return nil, err
}
return normalizeTVDetail(&resp), nil
default:
return nil, fmt.Errorf("tmdb: invalid media type for detail: %q", mediaType)
}
}
func normalizeMovieDetail(resp *movieDetailResponse) *MediaDetail {
detail := &MediaDetail{
MediaType: "movie",
ID: resp.ID,
IMDbID: resp.IMDbID,
Title: resp.Title,
OriginalTitle: resp.OriginalTitle,
Tagline: resp.Tagline,
Overview: resp.Overview,
PosterPath: resp.PosterPath,
BackdropPath: resp.BackdropPath,
ReleaseDate: resp.ReleaseDate,
Year: releaseYear(resp.ReleaseDate),
Runtime: resp.Runtime,
Genres: namesFromGenres(resp.Genres),
VoteAverage: resp.VoteAverage,
VoteCount: resp.VoteCount,
Status: resp.Status,
Homepage: resp.Homepage,
ContentRating: pickMovieCertification(resp.ReleaseDates),
}
for _, company := range resp.ProductionCompanies {
if name := strings.TrimSpace(company.Name); name != "" {
detail.ProductionCompanies = append(detail.ProductionCompanies, name)
}
}
if resp.ExternalIDs != nil {
detail.TVDBID = resp.ExternalIDs.TVDBID
if detail.IMDbID == "" {
detail.IMDbID = resp.ExternalIDs.IMDbID
}
}
if resp.Credits != nil {
detail.Cast = normalizeCast(resp.Credits.Cast)
detail.Director = pickDirector(resp.Credits.Crew)
}
if resp.Recommendations != nil {
detail.Recommendations = make([]MediaResult, 0, len(resp.Recommendations.Results))
for _, item := range resp.Recommendations.Results {
detail.Recommendations = append(detail.Recommendations, MediaResult{
ID: item.ID,
MediaType: "movie",
Title: item.Title,
Overview: item.Overview,
PosterPath: item.PosterPath,
BackdropPath: item.BackdropPath,
ReleaseDate: item.ReleaseDate,
Year: releaseYear(item.ReleaseDate),
Popularity: item.Popularity,
VoteAverage: item.VoteAverage,
})
}
}
return detail
}
func normalizeTVDetail(resp *tvDetailResponse) *MediaDetail {
detail := &MediaDetail{
MediaType: "series",
ID: resp.ID,
Title: resp.Name,
OriginalTitle: resp.OriginalName,
Tagline: resp.Tagline,
Overview: resp.Overview,
PosterPath: resp.PosterPath,
BackdropPath: resp.BackdropPath,
ReleaseDate: resp.FirstAirDate,
FirstAirDate: resp.FirstAirDate,
LastAirDate: resp.LastAirDate,
Year: releaseYear(resp.FirstAirDate),
Genres: namesFromGenres(resp.Genres),
VoteAverage: resp.VoteAverage,
VoteCount: resp.VoteCount,
Status: resp.Status,
Homepage: resp.Homepage,
NumberOfSeasons: resp.NumberOfSeasons,
NumberOfEpisodes: resp.NumberOfEpisodes,
ContentRating: pickTVRating(resp.ContentRatings),
}
if len(resp.EpisodeRunTime) > 0 {
detail.Runtime = resp.EpisodeRunTime[0]
}
for _, network := range resp.Networks {
if name := strings.TrimSpace(network.Name); name != "" {
detail.Networks = append(detail.Networks, name)
}
}
if resp.ExternalIDs != nil {
detail.IMDbID = resp.ExternalIDs.IMDbID
detail.TVDBID = resp.ExternalIDs.TVDBID
}
if resp.Credits != nil {
detail.Cast = normalizeCast(resp.Credits.Cast)
}
for _, person := range resp.CreatedBy {
if name := strings.TrimSpace(person.Name); name != "" {
detail.Creators = append(detail.Creators, name)
}
}
if resp.Recommendations != nil {
detail.Recommendations = make([]MediaResult, 0, len(resp.Recommendations.Results))
for _, item := range resp.Recommendations.Results {
detail.Recommendations = append(detail.Recommendations, MediaResult{
ID: item.ID,
MediaType: "series",
Title: item.Name,
Overview: item.Overview,
PosterPath: item.PosterPath,
BackdropPath: item.BackdropPath,
ReleaseDate: item.FirstAirDate,
Year: releaseYear(item.FirstAirDate),
Popularity: item.Popularity,
VoteAverage: item.VoteAverage,
})
}
}
return detail
}
func namesFromGenres(genres []genreEntry) []string {
if len(genres) == 0 {
return nil
}
out := make([]string, 0, len(genres))
for _, g := range genres {
if name := strings.TrimSpace(g.Name); name != "" {
out = append(out, name)
}
}
return out
}
// normalizeCast sorts by billing order and caps the result so the response
// payload stays bounded — the request detail UI surfaces only the top of the
// list anyway.
func normalizeCast(cast []castEntry) []MediaCastMember {
if len(cast) == 0 {
return nil
}
sorted := make([]castEntry, len(cast))
copy(sorted, cast)
sort.SliceStable(sorted, func(i, j int) bool {
return sorted[i].Order < sorted[j].Order
})
const maxCast = 24
if len(sorted) > maxCast {
sorted = sorted[:maxCast]
}
out := make([]MediaCastMember, 0, len(sorted))
for _, member := range sorted {
name := strings.TrimSpace(member.Name)
if name == "" {
continue
}
out = append(out, MediaCastMember{
Name: name,
Character: strings.TrimSpace(member.Character),
ProfilePath: member.ProfilePath,
Order: member.Order,
})
}
return out
}
func pickDirector(crew []crewEntry) string {
for _, member := range crew {
if strings.EqualFold(member.Job, "Director") {
return strings.TrimSpace(member.Name)
}
}
return ""
}
// pickMovieCertification picks the US theatrical certification if available,
// then falls back to any non-empty US certification, then any non-empty
// certification at all. Type 3 is theatrical in TMDB's release-type taxonomy.
func pickMovieCertification(rd *releaseDatesResponse) string {
if rd == nil {
return ""
}
var fallbackUS, fallbackAny string
for _, country := range rd.Results {
isUS := strings.EqualFold(country.ISO3166, "US")
for _, entry := range country.ReleaseDates {
cert := strings.TrimSpace(entry.Certification)
if cert == "" {
continue
}
if isUS && entry.Type == 3 {
return cert
}
if isUS && fallbackUS == "" {
fallbackUS = cert
}
if fallbackAny == "" {
fallbackAny = cert
}
}
}
if fallbackUS != "" {
return fallbackUS
}
return fallbackAny
}
func pickTVRating(cr *contentRatingsResponse) string {
if cr == nil {
return ""
}
var fallback string
for _, entry := range cr.Results {
rating := strings.TrimSpace(entry.Rating)
if rating == "" {
continue
}
if strings.EqualFold(entry.ISO3166, "US") {
return rating
}
if fallback == "" {
fallback = rating
}
}
return fallback
}
// GetExternalIDs fetches external IDs for a TMDB movie or TV entry.
// Uses the dedicated external_ids endpoint instead of append_to_response on
// the full detail, which would return a 100+ KB payload to extract a handful
// of identifiers.
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/external_ids", id)
case "tv":
path = fmt.Sprintf("/tv/%d/external_ids", id)
default:
return nil, fmt.Errorf("tmdb: invalid media type: %q", mediaType)
}
cacheKey := "external_ids:" + path
if c.externalIDCache != nil {
if cached, ok := c.externalIDCache.Get(cacheKey); ok {
return cloneExternalIDs(cached), nil
}
}
value, err, _ := c.cacheGroup.Do(cacheKey, func() (any, error) {
if c.externalIDCache != nil {
if cached, ok := c.externalIDCache.Get(cacheKey); ok {
return cached, nil
}
}
ids, err := c.fetchExternalIDs(ctx, path)
if err != nil {
return nil, err
}
cached := cloneExternalIDs(ids)
if c.externalIDCache != nil && c.responseCacheTTL > 0 {
c.externalIDCache.Set(cacheKey, cached, c.responseCacheTTL)
}
return cached, nil
})
if err != nil {
return nil, err
}
ids, ok := value.(*ExternalIDs)
if !ok {
return nil, fmt.Errorf("tmdb: invalid cached external IDs response")
}
return cloneExternalIDs(ids), nil
}
func cloneExternalIDs(ids *ExternalIDs) *ExternalIDs {
if ids == nil {
return nil
}
cloned := *ids
return &cloned
}
func (c *Client) fetchExternalIDs(ctx context.Context, path string) (*ExternalIDs, error) {
var resp ExternalIDs
if err := c.doGet(ctx, path, &resp); err != nil {
return nil, err
}
return &resp, nil
}