Единый источник полей отображаемого имени и один рендер полного ярлыка на всех путях (старт и «Обновить имя»/авто-перелив). Раньше старт давал полный «Название (режиссёр, год). Сезон 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>
200 lines
5.7 KiB
Go
200 lines
5.7 KiB
Go
package metadata
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.vakhrushev.me/av/jellybit/internal/logging"
|
|
)
|
|
|
|
const (
|
|
tmdbDefaultBaseURL = "https://api.themoviedb.org/3"
|
|
tmdbDefaultLanguage = "ru-RU"
|
|
)
|
|
|
|
// TMDBConfig — настройки клиента TMDB.
|
|
type TMDBConfig struct {
|
|
APIKey string
|
|
Proxy string
|
|
Timeout time.Duration
|
|
BaseURL string // пусто → api.themoviedb.org; задаётся в тестах
|
|
Language string // локаль возвращаемых названий; пусто → ru-RU
|
|
}
|
|
|
|
// TMDB — клиент The Movie Database (API v3, авторизация по api_key).
|
|
type TMDB struct {
|
|
apiKey string
|
|
baseURL string
|
|
language string
|
|
hc *http.Client
|
|
log *slog.Logger
|
|
}
|
|
|
|
// NewTMDB собирает клиент TMDB. logger nil → slog.Default().
|
|
func NewTMDB(cfg TMDBConfig, logger *slog.Logger) (*TMDB, error) {
|
|
if cfg.APIKey == "" {
|
|
return nil, fmt.Errorf("metadata: tmdb api_key required")
|
|
}
|
|
hc, err := newHTTPClient(cfg.Proxy, cfg.Timeout)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
base := cfg.BaseURL
|
|
if base == "" {
|
|
base = tmdbDefaultBaseURL
|
|
}
|
|
lang := cfg.Language
|
|
if lang == "" {
|
|
lang = tmdbDefaultLanguage
|
|
}
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
return &TMDB{apiKey: cfg.APIKey, baseURL: strings.TrimRight(base, "/"), language: lang, hc: hc, log: logger}, nil
|
|
}
|
|
|
|
func (t *TMDB) Name() string { return "tmdb" }
|
|
|
|
type tmdbSearchResp struct {
|
|
Results []struct {
|
|
ID int `json:"id"`
|
|
Title string `json:"title"` // movie
|
|
OriginalTitle string `json:"original_title"` // movie
|
|
Name string `json:"name"` // tv
|
|
OriginalName string `json:"original_name"` // tv
|
|
ReleaseDate string `json:"release_date"` // movie
|
|
FirstAirDate string `json:"first_air_date"` // tv
|
|
} `json:"results"`
|
|
}
|
|
|
|
// Search ищет фильм/сериал по названию и году.
|
|
func (t *TMDB) Search(ctx context.Context, q Query) ([]Candidate, error) {
|
|
var path string
|
|
params := url.Values{"api_key": {t.apiKey}, "query": {q.Title}, "include_adult": {"false"}, "language": {t.language}}
|
|
switch q.Type {
|
|
case Movie:
|
|
path = "/search/movie"
|
|
if q.Year > 0 {
|
|
params.Set("year", strconv.Itoa(q.Year))
|
|
}
|
|
case Series:
|
|
path = "/search/tv"
|
|
if q.Year > 0 {
|
|
params.Set("first_air_date_year", strconv.Itoa(q.Year))
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("metadata: tmdb: unknown type %q", q.Type)
|
|
}
|
|
|
|
var resp tmdbSearchResp
|
|
op := strings.TrimPrefix(path, "/") // search/movie | search/tv
|
|
if err := getJSON(ctx, t.hc, t.log, logging.ServiceTMDB, op, t.baseURL+path+"?"+params.Encode(), nil, &resp); err != nil {
|
|
return nil, fmt.Errorf("tmdb search: %w", err)
|
|
}
|
|
|
|
webPath := "movie"
|
|
if q.Type == Series {
|
|
webPath = "tv"
|
|
}
|
|
out := make([]Candidate, 0, len(resp.Results))
|
|
for _, r := range resp.Results {
|
|
title, orig, date := r.Title, r.OriginalTitle, r.ReleaseDate
|
|
if q.Type == Series {
|
|
title, orig, date = r.Name, r.OriginalName, r.FirstAirDate
|
|
}
|
|
out = append(out, Candidate{
|
|
Provider: "tmdb",
|
|
ID: strconv.Itoa(r.ID),
|
|
Title: title,
|
|
OriginalTitle: orig,
|
|
Year: yearOf(date),
|
|
URL: "https://www.themoviedb.org/" + webPath + "/" + strconv.Itoa(r.ID),
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
type tmdbTVResp struct {
|
|
Seasons []struct {
|
|
SeasonNumber int `json:"season_number"`
|
|
EpisodeCount int `json:"episode_count"`
|
|
} `json:"seasons"`
|
|
}
|
|
|
|
// SeasonEpisodeCounts возвращает число серий по сезонам сериала.
|
|
func (t *TMDB) SeasonEpisodeCounts(ctx context.Context, id string) (map[int]int, error) {
|
|
params := url.Values{"api_key": {t.apiKey}}
|
|
var resp tmdbTVResp
|
|
if err := getJSON(ctx, t.hc, t.log, logging.ServiceTMDB, "tv", t.baseURL+"/tv/"+url.PathEscape(id)+"?"+params.Encode(), nil, &resp); err != nil {
|
|
return nil, fmt.Errorf("tmdb tv %s: %w", id, err)
|
|
}
|
|
out := make(map[int]int, len(resp.Seasons))
|
|
for _, s := range resp.Seasons {
|
|
out[s.SeasonNumber] = s.EpisodeCount
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
type tmdbCreditsResp struct {
|
|
Crew []struct {
|
|
Job string `json:"job"`
|
|
Name string `json:"name"`
|
|
} `json:"crew"`
|
|
}
|
|
|
|
type tmdbTVDetailsResp struct {
|
|
CreatedBy []struct {
|
|
Name string `json:"name"`
|
|
} `json:"created_by"`
|
|
}
|
|
|
|
// Director возвращает режиссёра: для фильма — из crew (job "Director"), для
|
|
// сериала — создателей (created_by). Пусто — режиссёр не указан. Best-effort:
|
|
// вызывающий гасит ошибку.
|
|
func (t *TMDB) Director(ctx context.Context, mt MediaType, id string) (string, error) {
|
|
params := url.Values{"api_key": {t.apiKey}, "language": {t.language}}
|
|
if mt == Series {
|
|
var resp tmdbTVDetailsResp
|
|
if err := getJSON(ctx, t.hc, t.log, logging.ServiceTMDB, "tv",
|
|
t.baseURL+"/tv/"+url.PathEscape(id)+"?"+params.Encode(), nil, &resp); err != nil {
|
|
return "", fmt.Errorf("tmdb tv %s: %w", id, err)
|
|
}
|
|
names := make([]string, 0, len(resp.CreatedBy))
|
|
for _, c := range resp.CreatedBy {
|
|
if strings.TrimSpace(c.Name) != "" {
|
|
names = append(names, c.Name)
|
|
}
|
|
}
|
|
return strings.Join(names, ", "), nil
|
|
}
|
|
var resp tmdbCreditsResp
|
|
if err := getJSON(ctx, t.hc, t.log, logging.ServiceTMDB, "movie/credits",
|
|
t.baseURL+"/movie/"+url.PathEscape(id)+"/credits?"+params.Encode(), nil, &resp); err != nil {
|
|
return "", fmt.Errorf("tmdb movie credits %s: %w", id, err)
|
|
}
|
|
for _, c := range resp.Crew {
|
|
if c.Job == "Director" && strings.TrimSpace(c.Name) != "" {
|
|
return c.Name, nil
|
|
}
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
// yearOf достаёт год из даты вида "1999-03-31".
|
|
func yearOf(date string) int {
|
|
if len(date) < 4 {
|
|
return 0
|
|
}
|
|
y, err := strconv.Atoi(date[:4])
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return y
|
|
}
|