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 }