package metadata import ( "context" "encoding/json" "fmt" "io" "log/slog" "net/http" "net/url" "strconv" "strings" "sync" "time" ) const tvdbDefaultBaseURL = "https://api4.thetvdb.com/v4" // TVDBConfig — настройки клиента TheTVDB. type TVDBConfig struct { APIKey string Proxy string Timeout time.Duration BaseURL string // пусто → api4.thetvdb.com; задаётся в тестах } // TVDB — клиент TheTVDB (API v4). Токен получается логином по apikey и // кэшируется; при 401 выполняется повторный логин. Формы ответов сверены с // живым API v4 (см. integration_test.go). type TVDB struct { apiKey string baseURL string hc *http.Client log *slog.Logger mu sync.Mutex token string } // NewTVDB собирает клиент TVDB. logger nil → slog.Default(). func NewTVDB(cfg TVDBConfig, logger *slog.Logger) (*TVDB, error) { if cfg.APIKey == "" { return nil, fmt.Errorf("metadata: tvdb api_key required") } hc, err := newHTTPClient(cfg.Proxy, cfg.Timeout) if err != nil { return nil, err } base := cfg.BaseURL if base == "" { base = tvdbDefaultBaseURL } if logger == nil { logger = slog.Default() } return &TVDB{apiKey: cfg.APIKey, baseURL: strings.TrimRight(base, "/"), hc: hc, log: logger}, nil } func (t *TVDB) Name() string { return "tvdb" } // login получает и кэширует bearer-токен. func (t *TVDB) login(ctx context.Context) (string, error) { t.mu.Lock() defer t.mu.Unlock() if t.token != "" { return t.token, nil } var resp struct { Data struct { Token string `json:"token"` } `json:"data"` } t.log.Debug("tvdb: login (fetching bearer token)") if err := postJSON(ctx, t.hc, t.log, t.baseURL+"/login", map[string]string{"apikey": t.apiKey}, &resp); err != nil { return "", fmt.Errorf("tvdb login: %w", err) } if resp.Data.Token == "" { return "", fmt.Errorf("tvdb login: empty token") } t.token = resp.Data.Token return t.token, nil } // get делает авторизованный GET; при 401 один раз перелогинивается. func (t *TVDB) get(ctx context.Context, path string, out any) error { token, err := t.login(ctx) if err != nil { return err } status, raw, err := t.rawGet(ctx, path, token) if err != nil { return err } if status == http.StatusUnauthorized { t.log.Warn("tvdb: token expired, re-login", "path", path) t.mu.Lock() t.token = "" // сбрасываем протухший токен t.mu.Unlock() if token, err = t.login(ctx); err != nil { return err } if status, raw, err = t.rawGet(ctx, path, token); err != nil { return err } } if status != http.StatusOK { return fmt.Errorf("tvdb: status %d: %s", status, snippet(raw)) } if err := json.Unmarshal(raw, out); err != nil { return fmt.Errorf("tvdb: decode: %w (body: %s)", err, snippet(raw)) } return nil } func (t *TVDB) rawGet(ctx context.Context, path, token string) (int, []byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, t.baseURL+path, nil) if err != nil { return 0, nil, fmt.Errorf("tvdb: build request: %w", err) } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Accept", "application/json") start := time.Now() resp, err := t.hc.Do(req) if err != nil { t.log.Warn("tvdb: request failed", "host", req.URL.Host, "path", req.URL.Path, "duration", time.Since(start), "err", err) return 0, nil, fmt.Errorf("tvdb: request: %w", err) } defer func() { _ = resp.Body.Close() }() raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBody)) if err != nil { t.log.Warn("tvdb: read body failed", "host", req.URL.Host, "path", req.URL.Path, "err", err) return 0, nil, fmt.Errorf("tvdb: read body: %w", err) } t.log.Debug("tvdb: request done", "host", req.URL.Host, "path", req.URL.Path, "status", resp.StatusCode, "duration", time.Since(start)) return resp.StatusCode, raw, nil } type tvdbSearchResp struct { Data []struct { TVDBID string `json:"tvdb_id"` Name string `json:"name"` Year string `json:"year"` } `json:"data"` } // Search ищет сериал/фильм по названию и году. func (t *TVDB) Search(ctx context.Context, q Query) ([]Candidate, error) { typ := "series" if q.Type == Movie { typ = "movie" } params := url.Values{"query": {q.Title}, "type": {typ}} if q.Year > 0 { params.Set("year", strconv.Itoa(q.Year)) } t.log.Debug("tvdb: search", "type", q.Type, "title", q.Title, "year", q.Year) var resp tvdbSearchResp if err := t.get(ctx, "/search?"+params.Encode(), &resp); err != nil { return nil, fmt.Errorf("tvdb search: %w", err) } t.log.Debug("tvdb: search done", "title", q.Title, "results", len(resp.Data)) out := make([]Candidate, 0, len(resp.Data)) for _, r := range resp.Data { if r.TVDBID == "" { continue } year, _ := strconv.Atoi(r.Year) out = append(out, Candidate{ Provider: "tvdb", ID: r.TVDBID, Title: r.Name, Year: year, }) } return out, nil } type tvdbExtendedResp struct { Data struct { Episodes []struct { SeasonNumber int `json:"seasonNumber"` } `json:"episodes"` } `json:"data"` } // SeasonEpisodeCounts считает число серий по сезонам из расширенных данных. func (t *TVDB) SeasonEpisodeCounts(ctx context.Context, id string) (map[int]int, error) { var resp tvdbExtendedResp if err := t.get(ctx, "/series/"+url.PathEscape(id)+"/extended?meta=episodes&short=true", &resp); err != nil { return nil, fmt.Errorf("tvdb series %s: %w", id, err) } out := map[int]int{} for _, e := range resp.Data.Episodes { out[e.SeasonNumber]++ } return out, nil }