- новое поле [general].language (ru|en, дефолт en) — единый источник языка локализованного title детектора и локали запросов к метабазам; original_title всегда на языке оригинала - локаль TMDB (поиск + credits) выводится из него, [metadata.tmdb].language убран - промпт LLM явно задаёт язык title с fallback на оригинал
210 lines
6.6 KiB
Go
210 lines
6.6 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"
|
|
|
|
// TMDBConfig — настройки клиента TMDB.
|
|
type TMDBConfig struct {
|
|
APIKey string
|
|
Proxy string
|
|
Timeout time.Duration
|
|
BaseURL string // пусто → api.themoviedb.org; задаётся в тестах
|
|
// Language — абстрактный код языка вывода ("ru" | "en"); диалект локали TMDB
|
|
// выводит сам клиент (tmdbLocale). Знание диалекта живёт здесь, у провайдера,
|
|
// который на нём говорит, а не в общем слое конфига.
|
|
Language string
|
|
}
|
|
|
|
// tmdbLocale переводит абстрактный код языка вывода в диалект локали TMDB.
|
|
// Тотальна: непокрытый вход (пусто, неизвестный код) → en-US, чтобы запрос
|
|
// никогда не ушёл без локали (defense in depth поверх валидации конфига).
|
|
// Множество кодов задаёт config.validate ({ru, en}); при добавлении кода —
|
|
// синхронно добавь ветку здесь, иначе он молча уедет в en-US.
|
|
func tmdbLocale(lang string) string {
|
|
switch lang {
|
|
case "ru":
|
|
return "ru-RU"
|
|
default:
|
|
return "en-US"
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
return &TMDB{apiKey: cfg.APIKey, baseURL: strings.TrimRight(base, "/"), language: tmdbLocale(cfg.Language), 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
|
|
}
|