package metadata import ( "context" "fmt" "log/slog" "net/http" "net/url" "strconv" "strings" "time" ) const tmdbDefaultBaseURL = "https://api.themoviedb.org/3" // TMDBConfig — настройки клиента TMDB. type TMDBConfig struct { APIKey string Proxy string Timeout time.Duration BaseURL string // пусто → api.themoviedb.org; задаётся в тестах } // TMDB — клиент The Movie Database (API v3, авторизация по api_key). type TMDB struct { apiKey string baseURL string hc *http.Client log *slog.Logger } // NewTMDB собирает клиент TMDB. logger nil → slog.Default(). func NewTMDB(cfg TMDBConfig, logger *slog.Logger) (*TMDB, error) { if cfg.APIKey == "" { return nil, fmt.Errorf("metadata: tmdb api_key required") } hc, err := newHTTPClient(cfg.Proxy, cfg.Timeout) if err != nil { return nil, err } base := cfg.BaseURL if base == "" { base = tmdbDefaultBaseURL } if logger == nil { logger = slog.Default() } return &TMDB{apiKey: cfg.APIKey, baseURL: strings.TrimRight(base, "/"), hc: hc, log: logger}, nil } func (t *TMDB) Name() string { return "tmdb" } type tmdbSearchResp struct { Results []struct { ID int `json:"id"` Title string `json:"title"` // movie OriginalTitle string `json:"original_title"` // movie Name string `json:"name"` // tv OriginalName string `json:"original_name"` // tv ReleaseDate string `json:"release_date"` // movie FirstAirDate string `json:"first_air_date"` // tv } `json:"results"` } // Search ищет фильм/сериал по названию и году. func (t *TMDB) Search(ctx context.Context, q Query) ([]Candidate, error) { var path string params := url.Values{"api_key": {t.apiKey}, "query": {q.Title}, "include_adult": {"false"}} switch q.Type { case Movie: path = "/search/movie" if q.Year > 0 { params.Set("year", strconv.Itoa(q.Year)) } case Series: path = "/search/tv" if q.Year > 0 { params.Set("first_air_date_year", strconv.Itoa(q.Year)) } default: return nil, fmt.Errorf("metadata: tmdb: unknown type %q", q.Type) } t.log.Debug("tmdb: search", "type", q.Type, "title", q.Title, "year", q.Year) var resp tmdbSearchResp if err := getJSON(ctx, t.hc, t.log, t.baseURL+path+"?"+params.Encode(), nil, &resp); err != nil { return nil, fmt.Errorf("tmdb search: %w", err) } t.log.Debug("tmdb: search done", "title", q.Title, "results", len(resp.Results)) out := make([]Candidate, 0, len(resp.Results)) for _, r := range resp.Results { title, orig, date := r.Title, r.OriginalTitle, r.ReleaseDate if q.Type == Series { title, orig, date = r.Name, r.OriginalName, r.FirstAirDate } out = append(out, Candidate{ Provider: "tmdb", ID: strconv.Itoa(r.ID), Title: title, OriginalTitle: orig, Year: yearOf(date), }) } return out, nil } type tmdbTVResp struct { Seasons []struct { SeasonNumber int `json:"season_number"` EpisodeCount int `json:"episode_count"` } `json:"seasons"` } // SeasonEpisodeCounts возвращает число серий по сезонам сериала. func (t *TMDB) SeasonEpisodeCounts(ctx context.Context, id string) (map[int]int, error) { params := url.Values{"api_key": {t.apiKey}} var resp tmdbTVResp if err := getJSON(ctx, t.hc, t.log, t.baseURL+"/tv/"+url.PathEscape(id)+"?"+params.Encode(), nil, &resp); err != nil { return nil, fmt.Errorf("tmdb tv %s: %w", id, err) } out := make(map[int]int, len(resp.Seasons)) for _, s := range resp.Seasons { out[s.SeasonNumber] = s.EpisodeCount } return out, nil } // yearOf достаёт год из даты вида "1999-03-31". func yearOf(date string) int { if len(date) < 4 { return 0 } y, err := strconv.Atoi(date[:4]) if err != nil { return 0 } return y }