package main import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "runtime/debug" "strconv" "strings" "time" "golang.org/x/time/rate" ) type httpDoer interface { Do(*http.Request) (*http.Response, error) } // rateLimitedDoer wraps an httpDoer and rate-limits requests via a shared token bucket. type rateLimitedDoer struct { doer httpDoer limiter *rate.Limiter } // WithRateLimit wraps d with a rate limiter. The same limiter can be shared // across multiple doers to enforce a single global rate. If limiter is nil, d // is returned unwrapped. func WithRateLimit(d httpDoer, limiter *rate.Limiter) httpDoer { if limiter == nil { return d } return &rateLimitedDoer{doer: d, limiter: limiter} } func (r *rateLimitedDoer) Do(req *http.Request) (*http.Response, error) { if err := r.limiter.Wait(req.Context()); err != nil { return nil, err } return r.doer.Do(req) } type MovieFetcher interface { FetchMovieDetails(ctx context.Context, id int) (*Movie, error) } const ( tmdbBaseURL = "https://api.themoviedb.org" tmdbTimeout = 10 * time.Second maxPages = 50 ) var tmdbLists = []string{"top_rated", "popular", "now_playing", "upcoming"} // TMDBError carries the HTTP status code from a failed TMDB API response. type TMDBError struct { Path string StatusCode int Status string Body string } func (e *TMDBError) Error() string { return fmt.Sprintf("tmdb %s: %s: %s", e.Path, e.Status, e.Body) } type tmdbClient struct { apiKey string bearerToken string http httpDoer } func newTMDBClient(key string) tmdbClient { c := tmdbClient{http: &http.Client{Timeout: tmdbTimeout}} if strings.HasPrefix(key, "eyJ") { c.bearerToken = key } else { c.apiKey = key } return c } type tmdbRawMovie struct { ID int `json:"id"` Title string `json:"title"` ReleaseDate string `json:"release_date"` VoteAverage float64 `json:"vote_average"` GenreIDs []int `json:"genre_ids"` } type tmdbMovieDetail struct { ID int `json:"id"` Title string `json:"title"` ReleaseDate string `json:"release_date"` VoteAverage float64 `json:"vote_average"` Genres []struct { ID int `json:"id"` Name string `json:"name"` } `json:"genres"` PosterPath string `json:"poster_path"` BackdropPath string `json:"backdrop_path"` Credits *tmdbCredits `json:"credits,omitempty"` ExternalIDs *tmdbExternalIDs `json:"external_ids,omitempty"` } type tmdbExternalIDs struct { ImdbID string `json:"imdb_id"` } type tmdbCredits struct { Crew []struct { Job string `json:"job"` Name string `json:"name"` } `json:"crew"` } func (c tmdbClient) get(ctx context.Context, path string, query map[string]string) (*http.Response, error) { u, err := url.Parse(tmdbBaseURL + path) if err != nil { return nil, fmt.Errorf("build URL: %w", err) } q := u.Query() for k, v := range query { q.Set(k, v) } if c.apiKey != "" { q.Set("api_key", c.apiKey) } u.RawQuery = q.Encode() req, err := http.NewRequest(http.MethodGet, u.String(), nil) if err != nil { return nil, err } req.Header.Set("User-Agent", userAgent()) if c.bearerToken != "" { req.Header.Set("Authorization", "Bearer "+c.bearerToken) } resp, err := c.http.Do(req) if err != nil { return nil, err } if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) resp.Body.Close() return nil, &TMDBError{Path: path, StatusCode: resp.StatusCode, Status: resp.Status, Body: strings.TrimSpace(string(body))} } return resp, nil } func fetchGenreMap(ctx context.Context, c tmdbClient) (map[int]string, error) { resp, err := c.get(ctx, "/3/genre/movie/list", nil) if err != nil { return nil, err } defer resp.Body.Close() var result struct { Genres []struct { ID int `json:"id"` Name string `json:"name"` } `json:"genres"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, err } m := make(map[int]string, len(result.Genres)) for _, g := range result.Genres { m[g.ID] = g.Name } return m, nil } func fetchRawPage(ctx context.Context, c tmdbClient, list string, page int) ([]tmdbRawMovie, bool, error) { resp, err := c.get(ctx, "/3/movie/"+list, map[string]string{"page": strconv.Itoa(page)}) if err != nil { return nil, false, err } defer resp.Body.Close() var result struct { Results []tmdbRawMovie `json:"results"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, false, err } if len(result.Results) == 0 { return nil, false, nil } return result.Results, true, nil } type tmdbMovieStream struct { client tmdbClient seen map[int]struct{} listIdx int pageIdx int exhausted bool genresOnce bool genresMap map[int]string genresErr error } func newTMDBMovieStream(client tmdbClient, skip map[int]struct{}) *tmdbMovieStream { seen := make(map[int]struct{}, len(skip)) for id := range skip { seen[id] = struct{}{} } return &tmdbMovieStream{client: client, seen: seen} } // FetchNextPage returns up to a page of unseen movies from TMDB. // Returns (nil, nil) when the stream is exhausted. func (s *tmdbMovieStream) FetchNextPage(ctx context.Context) ([]Movie, error) { if !s.genresOnce { s.genresOnce = true s.genresMap, s.genresErr = fetchGenreMap(ctx, s.client) } if s.genresErr != nil { s.genresOnce = false return nil, s.genresErr } const perFetch = 20 var movies []Movie for len(movies) < perFetch && s.listIdx < len(tmdbLists) { list := tmdbLists[s.listIdx] s.pageIdx++ raw, hasMore, err := fetchRawPage(ctx, s.client, list, s.pageIdx) if err != nil { return nil, fmt.Errorf("fetch %s page %d: %w", list, s.pageIdx, err) } for _, r := range raw { if _, ok := s.seen[r.ID]; ok { continue } if len(movies) >= perFetch { break } s.seen[r.ID] = struct{}{} movies = append(movies, Movie{ ID: r.ID, Title: r.Title, Year: releaseYear(r.ReleaseDate), Score: r.VoteAverage, Genres: lookupGenres(r.GenreIDs, s.genresMap), }) } if !hasMore { s.listIdx++ s.pageIdx = 0 } } if len(movies) == 0 { s.exhausted = true return nil, nil } return movies, nil } func (c tmdbClient) FetchMovieDetails(ctx context.Context, id int) (*Movie, error) { path := fmt.Sprintf("/3/movie/%d?append_to_response=credits,external_ids", id) resp, err := c.get(ctx, path, nil) if err != nil { var tmdbErr *TMDBError if errors.As(err, &tmdbErr) && tmdbErr.StatusCode == http.StatusNotFound { return nil, nil } return nil, err } defer resp.Body.Close() var detail tmdbMovieDetail if err := json.NewDecoder(resp.Body).Decode(&detail); err != nil { return nil, fmt.Errorf("decode movie %d: %w", id, err) } m := &Movie{ ID: detail.ID, Title: detail.Title, Year: releaseYear(detail.ReleaseDate), Score: detail.VoteAverage, PosterPath: detail.PosterPath, BackdropPath: detail.BackdropPath, ReleaseDate: detail.ReleaseDate, } for _, g := range detail.Genres { m.Genres = append(m.Genres, g.Name) } if detail.Credits != nil { for _, c := range detail.Credits.Crew { if c.Job == "Director" { m.Director = c.Name break } } } if detail.ExternalIDs != nil { m.ImdbID = detail.ExternalIDs.ImdbID } return m, nil } func lookupGenres(ids []int, genres map[int]string) []string { var out []string for _, id := range ids { if name, ok := genres[id]; ok { out = append(out, name) } } return out } func releaseYear(date string) string { if date == "" { return "????" } return strings.SplitN(date, "-", 2)[0] } func userAgent() string { bi, ok := debug.ReadBuildInfo() if ok { return bi.Main.Path } return "popback" }