package metadata import ( "context" "encoding/json" "fmt" "io" "log/slog" "net/http" "net/url" "strconv" "strings" "sync" "time" "git.vakhrushev.me/av/jellybit/internal/logctx" "git.vakhrushev.me/av/jellybit/internal/logging" ) 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"` } // Тело запроса содержит apikey — postJSON его не логирует (только ext.*). if err := postJSON(ctx, t.hc, t.log, logging.ServiceTVDB, "login", 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 один раз перелогинивается. // operation — логическая операция для поля ext.operation. func (t *TVDB) get(ctx context.Context, operation, path string, out any) error { token, err := t.login(ctx) if err != nil { return err } status, raw, err := t.rawGet(ctx, operation, path, token) if err != nil { return err } if status == http.StatusUnauthorized { // Рутинное обновление протухшего токена — DEBUG (не «может стать проблемой»). logctx.FromOr(ctx, t.log).Debug("tvdb token expired, re-login") 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, operation, 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, operation, 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") log := logctx.FromOr(ctx, t.log) call := logging.ExtCall{Service: logging.ServiceTVDB, Operation: operation, Start: time.Now()} resp, err := t.hc.Do(req) if err != nil { call.Failure(log, err) return 0, nil, fmt.Errorf("tvdb: request: %w", err) } defer func() { _ = resp.Body.Close() }() call.Status = resp.StatusCode raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBody)) if err != nil { call.Failure(log, err) return 0, nil, fmt.Errorf("tvdb: read body: %w", err) } call.Success(log) 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)) } var resp tvdbSearchResp if err := t.get(ctx, "search", "/search?"+params.Encode(), &resp); err != nil { return nil, fmt.Errorf("tvdb search: %w", err) } 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, URL: "https://www.thetvdb.com/dereferrer/" + typ + "/" + r.TVDBID, }) } return out, nil } type tvdbExtendedResp struct { Data struct { Episodes []struct { SeasonNumber int `json:"seasonNumber"` } `json:"episodes"` } `json:"data"` } type tvdbCharactersResp struct { Data struct { Characters []struct { PeopleType string `json:"peopleType"` PersonName string `json:"personName"` } `json:"characters"` } `json:"data"` } // Director возвращает режиссёра из расширенных данных записи (characters с // peopleType "Director"). Пусто — режиссёр не указан. Best-effort: вызывающий // гасит ошибку. func (t *TVDB) Director(ctx context.Context, mt MediaType, id string) (string, error) { kind := "series" if mt == Movie { kind = "movies" } var resp tvdbCharactersResp if err := t.get(ctx, kind+"/extended", "/"+kind+"/"+url.PathEscape(id)+"/extended", &resp); err != nil { return "", fmt.Errorf("tvdb %s %s: %w", kind, id, err) } for _, c := range resp.Data.Characters { if c.PeopleType == "Director" && strings.TrimSpace(c.PersonName) != "" { return c.PersonName, nil } } return "", nil } // SeasonEpisodeCounts считает число серий по сезонам из расширенных данных. func (t *TVDB) SeasonEpisodeCounts(ctx context.Context, id string) (map[int]int, error) { var resp tvdbExtendedResp if err := t.get(ctx, "series/extended", "/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 }