Something went wrong. Try again.
let's backfill your watched movies on popfeed
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330package 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"}