recognition: глобальный переключатель языка вывода [general].language

- новое поле [general].language (ru|en, дефолт en) — единый источник языка
  локализованного title детектора и локали запросов к метабазам; original_title
  всегда на языке оригинала
- локаль TMDB (поиск + credits) выводится из него, [metadata.tmdb].language убран
- промпт LLM явно задаёт язык title с fallback на оригинал
This commit is contained in:
av
2026-07-24 14:09:04 +03:00
parent 8309ce0664
commit a2d8140e22
18 changed files with 556 additions and 48 deletions
+24 -14
View File
@@ -13,18 +13,32 @@ import (
"git.vakhrushev.me/av/jellybit/internal/logging"
)
const (
tmdbDefaultBaseURL = "https://api.themoviedb.org/3"
tmdbDefaultLanguage = "ru-RU"
)
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 string // локаль возвращаемых названий; пусто → ru-RU
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).
@@ -49,14 +63,10 @@ func NewTMDB(cfg TMDBConfig, logger *slog.Logger) (*TMDB, error) {
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
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" }
+29 -9
View File
@@ -109,13 +109,13 @@ func TestTMDB_ErrorStatus(t *testing.T) {
}
func TestTMDB_SearchDefaultLanguage(t *testing.T) {
// Язык не задан в конфиге → дефолт ru-RU; original_title не зависит от локали.
// Язык не задан в конфиге → дефолт en-US; original_title не зависит от локали.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("language"); got != "ru-RU" {
t.Errorf("language = %q, want ru-RU", got)
if got := r.URL.Query().Get("language"); got != "en-US" {
t.Errorf("language = %q, want en-US", got)
}
_, _ = w.Write([]byte(`{"results":[
{"id":155,"title":"Тёмный рыцарь","original_title":"The Dark Knight","release_date":"2008-07-18"}
{"id":155,"title":"The Dark Knight","original_title":"The Dark Knight","release_date":"2008-07-18"}
]}`))
}))
defer srv.Close()
@@ -124,21 +124,22 @@ func TestTMDB_SearchDefaultLanguage(t *testing.T) {
if err != nil {
t.Fatalf("Search: %v", err)
}
if got[0].Title != "Тёмный рыцарь" || got[0].OriginalTitle != "The Dark Knight" {
if got[0].Title != "The Dark Knight" || got[0].OriginalTitle != "The Dark Knight" {
t.Errorf("candidate = %+v", got[0])
}
}
func TestTMDB_SearchLanguageOverride(t *testing.T) {
func TestTMDB_SearchRussianLanguage(t *testing.T) {
// language = "ru" → диалект локали TMDB ru-RU (маппинг у провайдера).
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("language"); got != "en-US" {
t.Errorf("language = %q, want en-US", got)
if got := r.URL.Query().Get("language"); got != "ru-RU" {
t.Errorf("language = %q, want ru-RU", got)
}
_, _ = w.Write([]byte(`{"results":[]}`))
}))
defer srv.Close()
c, err := NewTMDB(TMDBConfig{APIKey: "k", BaseURL: srv.URL, Language: "en-US"}, nil)
c, err := NewTMDB(TMDBConfig{APIKey: "k", BaseURL: srv.URL, Language: "ru"}, nil)
if err != nil {
t.Fatalf("NewTMDB: %v", err)
}
@@ -147,6 +148,25 @@ func TestTMDB_SearchLanguageOverride(t *testing.T) {
}
}
func TestTMDB_DirectorCarriesLanguage(t *testing.T) {
// Локаль выведенного языка передаётся и в запрос credits (режиссёр).
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("language"); got != "ru-RU" {
t.Errorf("language = %q, want ru-RU", got)
}
_, _ = w.Write([]byte(`{"crew":[{"job":"Director","name":"Тимур Бекмамбетов"}]}`))
}))
defer srv.Close()
c, err := NewTMDB(TMDBConfig{APIKey: "k", BaseURL: srv.URL, Language: "ru"}, nil)
if err != nil {
t.Fatalf("NewTMDB: %v", err)
}
if _, err := c.Director(context.Background(), Movie, "1"); err != nil {
t.Fatalf("Director: %v", err)
}
}
func TestNewTMDB_RequiresKey(t *testing.T) {
if _, err := NewTMDB(TMDBConfig{}, nil); err == nil {
t.Fatal("want error without api_key")