Vellum/internal/providers/tmdb_test.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

244 lines
7.9 KiB
Go

package providers
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
// tmdbSrv answers both search endpoints from one handler, so a test can watch
// what each side did without two servers to keep in sync.
func tmdbSrv(t *testing.T, fn func(endpoint string, r *http.Request) (string, int)) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, status := fn(r.URL.Path, r)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
t.Cleanup(srv.Close)
return srv
}
func TestTMDBMapsMovieAndTV(t *testing.T) {
// Each endpoint records only its own request: the two handlers run on
// different goroutines, so a shared capture would be a race.
var gotMovieKey, gotMovieQuery, gotMovieYear, gotTVKey, gotTVYear string
srv := tmdbSrv(t, func(endpoint string, r *http.Request) (string, int) {
q := r.URL.Query()
switch endpoint {
case "/search/movie":
gotMovieKey, gotMovieQuery, gotMovieYear = q.Get("api_key"), q.Get("query"), q.Get("year")
return `{"results":[
{"id": 603, "title": "The Matrix", "overview": "A hacker learns the truth.",
"release_date": "1999-03-31", "poster_path": "/matrix.jpg", "popularity": 88.1}
]}`, http.StatusOK
case "/search/tv":
gotTVKey, gotTVYear = q.Get("api_key"), q.Get("first_air_date_year")
return `{"results":[
{"id": 1399, "name": "Game of Thrones", "overview": "Noble houses fight for a throne.",
"first_air_date": "2011-04-17", "poster_path": "/got.jpg", "popularity": 120.5}
]}`, http.StatusOK
}
return `{"results":[]}`, http.StatusOK
})
p := NewTMDB("secret-key")
p.BaseURL = srv.URL
cands, err := p.Search(context.Background(), Query{Title: "The Matrix", Year: 1999})
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(cands) != 2 {
t.Fatalf("got %d candidates, want 2", len(cands))
}
// Merged by TMDB popularity: the show (120.5) outranks the film (88.1).
show, movie := cands[0], cands[1]
if show.Title != "Game of Thrones" || movie.Title != "The Matrix" {
t.Fatalf("merge order wrong: %q then %q", show.Title, movie.Title)
}
if movie.Provider != "tmdb" || movie.ExternalID != "movie/603" {
t.Errorf("movie id = %q (provider %q), want movie/603", movie.ExternalID, movie.Provider)
}
if movie.Year != 1999 {
t.Errorf("movie Year = %d, want 1999", movie.Year)
}
if movie.Description != "A hacker learns the truth." {
t.Errorf("movie Description = %q", movie.Description)
}
if movie.CoverURL != "https://image.tmdb.org/t/p/w500/matrix.jpg" {
t.Errorf("movie CoverURL = %q", movie.CoverURL)
}
if show.ExternalID != "tv/1399" || show.Year != 2011 {
t.Errorf("show id/year = %q/%d, want tv/1399/2011", show.ExternalID, show.Year)
}
if show.CoverURL != "https://image.tmdb.org/t/p/w500/got.jpg" {
t.Errorf("show CoverURL = %q", show.CoverURL)
}
if gotMovieKey != "secret-key" || gotTVKey != "secret-key" {
t.Errorf("api_key = movie %q, tv %q, want secret-key", gotMovieKey, gotTVKey)
}
if gotMovieQuery != "The Matrix" {
t.Errorf("movie query = %q", gotMovieQuery)
}
// The two namespaces spell the year filter differently; both must get it.
if gotMovieYear != "1999" || gotTVYear != "1999" {
t.Errorf("year filter = movie %q, tv %q, want 1999", gotMovieYear, gotTVYear)
}
}
func TestTMDBSearchesConcurrently(t *testing.T) {
var arrived int32
var overlapped atomic.Bool
both := make(chan struct{})
srv := tmdbSrv(t, func(_ string, _ *http.Request) (string, int) {
// Neither endpoint answers until both requests are in flight, so a
// sequential implementation deadlocks here rather than passing.
if atomic.AddInt32(&arrived, 1) == 2 {
overlapped.Store(true)
close(both)
}
select {
case <-both:
case <-time.After(2 * time.Second):
}
return `{"results":[{"id": 1, "title": "Anything", "popularity": 1}]}`, http.StatusOK
})
p := NewTMDB("secret-key")
p.BaseURL = srv.URL
cands, err := p.Search(context.Background(), Query{Title: "Anything"})
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(cands) != 2 {
t.Errorf("got %d candidates, want one per endpoint", len(cands))
}
if n := atomic.LoadInt32(&arrived); n != 2 {
t.Errorf("endpoints hit %d times, want 2", n)
}
if !overlapped.Load() {
t.Error("the two searches did not overlap: they ran one after the other")
}
}
// TMDB keeps films and television in one numeric id space: film 603 and show
// 603 are different works. A bare id would write them identically into
// provider_ids, so the namespace has to be part of what gets stored.
func TestTMDBExternalIDIsNamespaced(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
// The same id from both endpoints is the collision under test.
if strings.Contains(r.URL.Path, "/movie") {
_, _ = w.Write([]byte(`{"results":[{"id":603,"title":"Film 603","release_date":"1999-03-31","popularity":10}]}`))
return
}
_, _ = w.Write([]byte(`{"results":[{"id":603,"name":"Show 603","first_air_date":"2011-04-17","popularity":20}]}`))
}))
defer srv.Close()
p := NewTMDB("secret-key")
p.BaseURL = srv.URL
cands, err := p.Search(context.Background(), Query{Title: "603"})
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(cands) != 2 {
t.Fatalf("got %d candidates, want 2", len(cands))
}
if cands[0].ExternalID == cands[1].ExternalID {
t.Fatalf("a film and a show sharing id 603 both produced ExternalID %q", cands[0].ExternalID)
}
}
func TestTMDBOneEndpointFailureKeepsOtherResults(t *testing.T) { // The movie endpoint only fails once the TV response has been written, so
// this is an ordering guarantee, not a race the test happens to win.
tvDone := make(chan struct{})
srv := tmdbSrv(t, func(endpoint string, _ *http.Request) (string, int) {
switch endpoint {
case "/search/tv":
close(tvDone)
return `{"results":[{"id": 1396, "name": "Breaking Bad", "popularity": 200}]}`, http.StatusOK
default:
select {
case <-tvDone:
case <-time.After(2 * time.Second):
}
return `{"status_message":"boom"}`, http.StatusInternalServerError
}
})
p := NewTMDB("secret-key")
p.BaseURL = srv.URL
cands, err := p.Search(context.Background(), Query{Title: "Breaking Bad"})
if err != nil {
t.Fatalf("Search: %v (one failing endpoint must not discard the other)", err)
}
if len(cands) != 1 || cands[0].Title != "Breaking Bad" {
t.Fatalf("got %+v, want just the TV match", cands)
}
}
func TestTMDBEmptyResultsAreNotAnError(t *testing.T) {
srv := tmdbSrv(t, func(_ string, _ *http.Request) (string, int) {
return `{"page":1,"results":[],"total_pages":1,"total_results":0}`, http.StatusOK
})
p := NewTMDB("secret-key")
p.BaseURL = srv.URL
cands, err := p.Search(context.Background(), Query{Title: "Nothing At All Like This"})
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(cands) != 0 {
t.Errorf("got %d candidates, want 0", len(cands))
}
}
func TestTMDBWithoutUsableTitleDoesNotCallOut(t *testing.T) {
var hits int32
srv := tmdbSrv(t, func(_ string, _ *http.Request) (string, int) {
atomic.AddInt32(&hits, 1)
return `{"results":[{"id": 1, "title": "Unwanted", "popularity": 1}]}`, http.StatusOK
})
p := NewTMDB("secret-key")
p.BaseURL = srv.URL
for _, q := range []Query{{}, {Title: " "}} {
cands, err := p.Search(context.Background(), q)
if err != nil {
t.Fatalf("Search(%+v): %v", q, err)
}
if len(cands) != 0 {
t.Errorf("Search(%+v) returned %d candidates, want 0", q, len(cands))
}
}
if n := atomic.LoadInt32(&hits); n != 0 {
t.Errorf("TMDB was called %d times with no usable title, want 0", n)
}
}
func TestTMDBWithoutKeyDoesNothing(t *testing.T) {
p := NewTMDB("")
cands, err := p.Search(context.Background(), Query{Title: "The Matrix"})
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(cands) != 0 {
t.Errorf("got %d candidates with no key, want 0", len(cands))
}
}