Files
jellybit/internal/metadata/tmdb.go
T
av a2d8140e22 recognition: глобальный переключатель языка вывода [general].language
- новое поле [general].language (ru|en, дефолт en) — единый источник языка
  локализованного title детектора и локали запросов к метабазам; original_title
  всегда на языке оригинала
- локаль TMDB (поиск + credits) выводится из него, [metadata.tmdb].language убран
- промпт LLM явно задаёт язык title с fallback на оригинал
2026-07-24 14:09:04 +03:00

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
}