Vellum/internal/providers/tmdb.go
mithral 3207b89205 Add a TMDB provider for video: films and television
Five providers existed and none covered the "video" kind, so every movie
and show got a title from its filename and nothing else - no poster, no
plot, no cast, no year. That is the gap between "a folder of videos with a
nice web UI" and a media server.

TMDB keeps films and television in separate namespaces behind separate
endpoints, and a Query does not say which one it is. Rather than guessing
from the title, Search asks both at once and merges by TMDB popularity,
which is the only ranking signal available without a second request per
item.

Speed is the point, so the two requests run concurrently over one derived
context on a single shared client, and whichever fails first cancels the
other: one dead endpoint cannot hold the result hostage while the healthy
one is waited out. Verified red-green - serialising the two fetches makes
TestTMDBSearchesConcurrently fail at 2s.

Two corrections made in review, both found by reading the code rather than
trusting the report:

1. ExternalID was the bare TMDB id, so film 603 and show 603 - different
   works - would have been stored identically in provider_ids. Namespaced
   as movie/603 and tv/603. Nothing resolves an item from an external_id
   today, so this was latent rather than broken, but the table is populated
   as enrichment runs and correcting it later would have needed a data
   migration. Pinned by TestTMDBExternalIDIsNamespaced.

2. Query.Year was added but never populated, so the year filter - the main
   way to tell a remake from the original - never applied. catalog.Item
   already carried Year; the enricher now passes it.

No cover bytes are fetched here: the provider returns image URLs and the
existing enricher caches what it uses. A missing key logs the env var and
skips, so the feature is discoverable from the boot log.

Not verified: the live TMDB API. Tests run against httptest servers only.
2026-10-12 01:17:48 -04:00

185 lines
5.2 KiB
Go

package providers
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// TMDB matches video — films and television — against The Movie Database.
//
// A Query does not say which of the two it is, and TMDB keeps them in separate
// namespaces behind separate endpoints, so Search asks both at once rather than
// guessing from the title. The results are merged by TMDB's own popularity,
// which is the only ranking signal available without a second request per item.
type TMDB struct {
APIKey string
BaseURL string
Client *http.Client
}
// NewTMDB builds the provider. An empty key yields a provider that finds
// nothing, so a misconfiguration degrades rather than erroring.
func NewTMDB(apiKey string) *TMDB {
return &TMDB{
APIKey: apiKey,
BaseURL: "https://api.themoviedb.org/3",
// One client for both requests: connection reuse across the pair is
// most of what makes the concurrent form cheap.
Client: &http.Client{Timeout: 15 * time.Second},
}
}
func (p *TMDB) Name() string { return "tmdb" }
func (p *TMDB) Kinds() []string { return []string{"video"} }
// tmdbResponse is the subset of a TMDB search payload we read. Film and
// television share the envelope but not the field names, so the few that differ
// are read together and picked between below.
type tmdbResponse struct {
Results []struct {
ID int `json:"id"`
Title string `json:"title"`
Name string `json:"name"`
Overview string `json:"overview"`
ReleaseDate string `json:"release_date"`
FirstAirDate string `json:"first_air_date"`
PosterPath string `json:"poster_path"`
Popularity float64 `json:"popularity"`
} `json:"results"`
}
// tmdbHit carries a candidate with the popularity it was ranked by, which
// Candidate has no field for.
type tmdbHit struct {
cand Candidate
popularity float64
}
func (p *TMDB) Search(ctx context.Context, q Query) ([]Candidate, error) {
title := strings.TrimSpace(q.Title)
if p.APIKey == "" || title == "" {
return nil, nil
}
// The two endpoints run at once: the call costs one response time, not two.
// Whichever fails first cancels the other, so one dead endpoint cannot hold
// the result hostage while the healthy one is waited out.
ctx, cancel := context.WithCancel(ctx)
defer cancel()
var (
wg sync.WaitGroup
hits [2][]tmdbHit
errs [2]error
fetch = func(i int, endpoint, kind string) {
defer wg.Done()
hits[i], errs[i] = p.search(ctx, endpoint, kind, title, q.Year)
if errs[i] != nil {
cancel()
}
}
)
wg.Add(2)
go fetch(0, "/search/movie", "movie")
go fetch(1, "/search/tv", "tv")
wg.Wait()
merged := append(hits[0], hits[1]...)
if len(merged) == 0 && (errs[0] != nil || errs[1] != nil) {
// Only a total failure is an error. One endpoint answering is a result.
return nil, errors.Join(errs[0], errs[1])
}
sort.SliceStable(merged, func(i, j int) bool {
return merged[i].popularity > merged[j].popularity
})
out := make([]Candidate, 0, len(merged))
for _, h := range merged {
out = append(out, h.cand)
}
return out, nil
}
// search queries one TMDB endpoint and maps its results.
func (p *TMDB) search(ctx context.Context, endpoint, kind, title string, year int) ([]tmdbHit, error) {
params := url.Values{}
params.Set("api_key", p.APIKey)
params.Set("query", title)
params.Set("limit", "5")
if year > 0 {
// The two namespaces spell the year filter differently.
if kind == "movie" {
params.Set("year", strconv.Itoa(year))
} else {
params.Set("first_air_date_year", strconv.Itoa(year))
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
strings.TrimRight(p.BaseURL, "/")+endpoint+"?"+params.Encode(), nil)
if err != nil {
return nil, err
}
resp, err := p.Client.Do(req)
if err != nil {
return nil, fmt.Errorf("tmdb: %s: %w", kind, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("tmdb: %s: status %d", kind, resp.StatusCode)
}
var parsed tmdbResponse
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
return nil, fmt.Errorf("tmdb: %s: decode: %w", kind, err)
}
out := make([]tmdbHit, 0, len(parsed.Results))
for _, r := range parsed.Results {
name := r.Title
if name == "" {
name = r.Name // television has no title, only a name
}
if name == "" {
continue
}
date := r.ReleaseDate
if date == "" {
date = r.FirstAirDate
}
out = append(out, tmdbHit{
cand: Candidate{
Provider: p.Name(),
// Namespaced: TMDB keeps films and television in separate id
// spaces, so film 603 and show 603 are different works. A bare
// id would store them identically and collide in provider_ids.
ExternalID: kind + "/" + strconv.Itoa(r.ID),
Title: name,
Year: parseYear(date),
Description: r.Overview,
CoverURL: tmdbPoster(r.PosterPath),
},
popularity: r.Popularity,
})
}
return out, nil
}
// tmdbPoster expands TMDB's relative poster path into a full image URL. The
// bytes are never fetched here: the enricher caches covers it actually uses.
func tmdbPoster(path string) string {
if path == "" {
return ""
}
return "https://image.tmdb.org/t/p/w500" + path
}