Files
jellybit/internal/metadata/tvdb.go
T
avandClaude Opus 4.8 02d4ecc2aa display_name: слоистое разрешение полей + сохранение режиссёра из контекста
Единый источник полей отображаемого имени и один рендер полного ярлыка на
всех путях (старт и «Обновить имя»/авто-перелив). Раньше старт давал полный
«Название (режиссёр, год). Сезон N» но выбрасывал структуру, а перелив по
распознаванию — усечённый «Title (Year)».

- Слоистое разрешение скаляров имени: override → recognition(+match) →
  новый базовый слой «контекст» (download.parsed_context, JSON naming.Fields).
- naming: публичные Fields/Label/Derive, вынесен единый рендер; удалён
  FormatTitleYear. Сводка сезонов вынесена в recognize.SeasonSummary.
- Режиссёр из метабазы (решение A2): TMDB/TVDB credits через опциональный
  metadata.DirectorProvider; авто-матч кладёт в plan.Director, ручной выбор
  кандидата тянет credits и пиннит ovrDirector. Метабаза бьёт контекст.
- refreshDisplayNameLocked строит полный ярлык из эффективных полей;
  инфо-панель ревью показывает загруженного режиссёра.
- Миграция 0011_parsed_context + ER-схема. Всё косметика: на пути/раскладку
  не влияет, приём/вывод имени не валятся (best-effort).

Закрывает беклог-задачу «Кнопка „Обновить имя“: полный формат ярлыка».
OpenSpec: archive/2026-07-11-field-resolution-display-name (ingest,
recognition, metadata-match, review).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 11:50:08 +03:00

234 lines
6.9 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.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
}