Files
jellybit/internal/metadata/tvdb.go
T
avandClaude Opus 4.8 612344bab3 конвенции: перенести механизируемое в golangci-lint и internal/archrules
Правило, которое проверяет машина, не должно оставаться прозой: файл конвенций
на сотни строк размазывает внимание по тривиальному — модель добросовестно
проверит именование полей лога и не дойдёт до формы решения.

Включены sloglint (константный msg, стиль ключ-значение), forbidigo (fmt.Print*,
os.Getenv, time.Now мимо store.Now), errorlint (сравнение ошибок), depguard
(сторонние пакеты ошибок). internal/archrules — сканеры на то, что линтером не
выражается: направление зависимостей ядро↔транспорты, AUTOINCREMENT и серверное
время в новых миграциях, матчинг ошибки по тексту.

Код приведён к правилам: logging.StartCall как единая точка отсчёта длительности
внешних вызовов, store.Now вместо time.Now в httpapi и часах воркера,
slog.DiscardHandler в тестах.

Перенесённое вычеркнуто из docs/conventions/* и openspec/config.yaml — прозой
осталось только то, что правилом не выражается.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 18:17:40 +03:00

234 lines
6.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.StartCall(logging.ServiceTVDB, operation)
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
}