Files
jellybit/internal/metadata/tvdb.go
T
av fdbc781197 metadata: TVDB отдаёт локализованное название и оригинал
- локаль из [general].language применяется при разборе ответа /search, а в
  запрос не уходит: параметр language у TVDB — фильтр выдачи, а не селектор
  перевода (ADR-2026-08-07)
- Title берётся из блока translations с тотальным фолбэком на primary name,
  OriginalTitle — из primary name; форма ответа сверена по документации и
  живым прогоном не подтверждена (docs/research)
- неожиданная форма ответа даёт WARN: признак — отсутствие во всей выдаче
  ключей языка ожидаемого вида, а не неудача разбора блока
2026-08-07 15:17:05 +03:00

337 lines
13 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; задаётся в тестах
// Language — абстрактный код языка вывода ("ru" | "en"); диалект локали TVDB
// (трёхбуквенный код) выводит сам клиент (tvdbLocale). Знание диалекта живёт
// здесь, у провайдера, который на нём говорит, а не в общем слое конфига.
Language string
}
// tvdbLocale переводит абстрактный код языка вывода в код языка TVDB
// (трёхбуквенный, ISO 639-2). Тотальна: непокрытый вход (пусто, неизвестный код)
// → eng, чтобы поиск перевода никогда не шёл по пустому ключу (тогда фолбэк
// срабатывал бы всегда и молча). Множество кодов задаёт config.validate
// ({ru, en}); при добавлении кода — синхронно добавь ветку здесь.
func tvdbLocale(lang string) string {
switch lang {
case "ru":
return "rus"
default:
return "eng"
}
}
// TVDB — клиент TheTVDB (API v4). Токен получается логином по apikey и
// кэшируется; при 401 выполняется повторный логин. Формы ответов сверены с
// живым API v4 (см. integration_test.go) — кроме блока переводов в выдаче
// поиска: он взят из публичной документации и живым прогоном не подтверждён
// (docs/research/tvdb-search-translations.md).
type TVDB struct {
apiKey string
baseURL string
language 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, "/"),
language: tvdbLocale(cfg.Language),
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"`
// Translations — карта «код языка → название». Тип сырой намеренно:
// строгий тип дал бы косметическому полю право провалить json.Unmarshal
// всего ответа и убить кандидатов, которые сейчас приезжают нормально.
// Форма поля живым API не подтверждена (см. docs/research/).
Translations json.RawMessage `json:"translations"`
} `json:"data"`
}
// translatedName достаёт из сырого блока переводов название на языке lang.
//
// Второе значение — НЕ «перевода нет», а «форма ответа та, что мы предположили»:
// нёс ли блок хоть один ключ вида трёхбуквенного кода языка. Разбор блока таким
// признаком быть не может: json.Unmarshal успешно кладёт в карту и `null`, и
// `{}`, и словарь двухбуквенных кодов, а именно двухбуквенные коды — главный
// названный риск этого изменения (docs/research/tvdb-search-translations.md).
// Признак «разобралось» промолчал бы ровно там, где нужен сигнал.
//
// Негодная форма блока при этом не ошибка разбора ответа, а тотальный фолбэк на
// primary name: косметическое поле не получает права уронить выдачу поиска.
//
// Ключ ищется регистронезависимо — молчаливый фолбэк из-за регистра неотличим от
// «перевода нет». Выбор среди совпавших детерминирован: порядок обхода карты в Go
// случаен, а EqualFold совпадает и с `RUS`, и с юникод-эквивалентами простого
// case-folding, так что «первый попавшийся» давал бы разное имя папки от прогона
// к прогону на одном и том же ответе.
func translatedName(raw json.RawMessage, lang string) (name string, sawLangKeys bool) {
if len(raw) == 0 {
return "", false
}
var m map[string]string
if err := json.Unmarshal(raw, &m); err != nil || len(m) == 0 {
// `null` и `{}` разбираются без ошибки, но полезной нагрузки не несут —
// от отсутствия блока они неотличимы, и признаком формы быть не могут.
return "", false
}
best := ""
for k := range m {
if len(k) == 3 {
sawLangKeys = true
}
if !strings.EqualFold(k, lang) {
continue
}
switch {
case best == "", k == lang, best != lang && k < best:
best = k
}
}
if best == "" {
return "", sawLangKeys
}
return strings.TrimSpace(m[best]), sawLangKeys
}
// Search ищет сериал/фильм по названию и году.
//
// Параметр языка в запрос НЕ передаётся: у /search TVDB он фильтрует выдачу по
// основному языку записи, а не выбирает перевод, и сузил бы результат ровно на
// иноязычных записях. Локаль работает только на разборе ответа
// (openspec/specs/metadata-match, docs/research/tvdb-search-translations.md).
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))
sawLangKeys := false
for _, r := range resp.Data {
if r.TVDBID == "" {
continue
}
year, _ := strconv.Atoi(r.Year)
title, sawKeys := translatedName(r.Translations, t.language)
sawLangKeys = sawLangKeys || sawKeys
if title == "" {
title = r.Name // фолбэк тотален: перевода нет, он пуст или блок негоден
}
out = append(out, Candidate{
Provider: "tvdb",
ID: r.TVDBID,
Title: title,
OriginalTitle: r.Name,
Year: year,
URL: "https://www.thetvdb.com/dereferrer/" + typ + "/" + r.TVDBID,
})
}
// Во всей выдаче не встретилось ни одного трёхбуквенного кода языка —
// подозрение, что форма ответа не та, что записана в разведке. Штатное
// «перевода на этот язык нет» под условие не подпадает: там коды есть, просто
// нужного среди них нет. Один чекпоинт на операцию.
//
// Уровень WARN, а не DEBUG: это не рутина, а «наше предположение о внешнем
// контракте, возможно, неверно» (docs/conventions/logging.md — «команде, может
// стать проблемой»). DEBUG в проде выключен, а деградация здесь молчаливая:
// названия тихо уедут в фолбэк, и заметить это будет нечем. Ср. соседнее
// решение про refresh токена выше — там DEBUG осознан, случай ровно обратный.
if len(out) > 0 && !sawLangKeys {
logctx.FromOr(ctx, t.log).Warn("tvdb search returned no language-coded translations", "tvdb_locale", t.language)
}
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
}