Files
jellybit/internal/config/config.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

368 lines
16 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 config загружает конфигурацию jellybit из TOML-файла.
package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"time"
"github.com/pelletier/go-toml/v2"
)
// DefaultPath — имя конфига по умолчанию: ищется в рабочей директории
// процесса. Переопределяется опцией --config=path.
const DefaultPath = "config.toml"
// Config — корневая конфигурация сервиса (см. config.example.toml).
type Config struct {
General General `toml:"general"`
QBittorrent QBittorrent `toml:"qbittorrent"`
Paths Paths `toml:"paths"`
Storage Storage `toml:"storage"`
LLM LLM `toml:"llm"`
Metadata Metadata `toml:"metadata"`
Jellyfin Jellyfin `toml:"jellyfin"`
Worker Worker `toml:"worker"`
Recognition Recognition `toml:"recognition"`
Telegram Telegram `toml:"telegram"`
HTTP HTTP `toml:"http"`
Log Log `toml:"log"`
}
// General — общие настройки приложения.
type General struct {
// Timezone — таймзона ОТОБРАЖЕНИЯ времени в веб-UI (IANA, напр.
// "Europe/Moscow"). Хранение всегда UTC; настройка влияет только на рендеринг.
// Пусто → UTC. База зон встроена (time/tzdata), поэтому имя валидируется
// одинаково на любом хосте (см. DisplayLocation).
Timezone string `toml:"timezone"`
// Language — язык локализованного вывода: `title` от LLM-детектора и
// режиссёр/локаль запросов к метабазам. Допустимо: "ru" | "en", пусто → "en"
// (см. ContentLanguage). `original_title` от настройки не зависит — всегда на
// языке оригинала. Диалект локали провайдера (напр. TMDB ru-RU/en-US) выводит
// сам провайдер из этого кода.
Language string `toml:"language"`
}
// QBittorrent — доступ к qBittorrent WebUI и раскладка путей загрузок.
type QBittorrent struct {
URL string `toml:"url"`
Username string `toml:"username"`
Password string `toml:"password"`
// Category — категория для добавляемых jellybit раздач (push, savepath).
Category string `toml:"category"`
// Tag — метка для усыновления существующих раздач (pull, не трогает
// категорию/savepath). Discovery подхватывает раздачи с этой категорией
// ИЛИ этим тегом.
Tag string `toml:"tag"`
SavePath string `toml:"savepath"`
PathMap map[string]string `toml:"path_map"`
}
// Paths — хост-пути медиа-песочницы (см. docs/specs/architecture.md).
type Paths struct {
Downloads string `toml:"downloads"`
Movies string `toml:"movies"`
Series string `toml:"series"`
}
// Storage — расположение БД.
type Storage struct {
DBPath string `toml:"db_path"`
}
// LLM — провайдер распознавания (дискриминатор type).
type LLM struct {
Type string `toml:"type"`
BaseURL string `toml:"base_url"`
APIKey string `toml:"api_key"`
Model string `toml:"model"`
Proxy string `toml:"proxy"`
Timeout Duration `toml:"timeout"`
MaxRetries int `toml:"max_retries"`
}
// Metadata — внешние базы метаданных (опциональны).
type Metadata struct {
TMDB MetadataProvider `toml:"tmdb"`
TVDB MetadataProvider `toml:"tvdb"`
TVMaze MetadataProvider `toml:"tvmaze"` // без ключа, только сериалы
}
// MetadataProvider — настройки одного провайдера метаданных. У keyless-баз
// (TVMaze) поле api_key не используется. Язык названий задаёт глобальный
// [general].language (локаль провайдера выводится из него), отдельной настройки
// у провайдера нет.
type MetadataProvider struct {
Enabled bool `toml:"enabled"`
APIKey string `toml:"api_key"`
Proxy string `toml:"proxy"`
Timeout Duration `toml:"timeout"`
}
// Jellyfin — пересканирование медиатеки после раскладки (опц.). Включается
// конфигом; без него скан не дёргается.
type Jellyfin struct {
Enabled bool `toml:"enabled"`
URL string `toml:"url"`
APIKey string `toml:"api_key"`
Proxy string `toml:"proxy"` // опц. HTTP-прокси
Timeout Duration `toml:"timeout"`
}
// Worker — параметры фонового цикла.
type Worker struct {
PollInterval Duration `toml:"poll_interval"`
StuckAfter Duration `toml:"stuck_after"`
MagnetTimeout Duration `toml:"magnet_timeout"`
// CatchTimeout — сколько пойманная (catched) загрузка может ждать добавления
// в qBittorrent, прежде чем счесть его невозможным и увести задачу в failed.
// Редкий предохранитель на случай устойчивой недоступности qBittorrent.
CatchTimeout Duration `toml:"catch_timeout"`
// SourceMissingThreshold — сколько подряд тиков сверки без раздачи в
// qBittorrent нужно, чтобы счесть источник удалённым (дебаунс пропажи,
// см. state-reconciliation). Любое появление раздачи сбрасывает счётчик.
SourceMissingThreshold int `toml:"source_missing_threshold"`
}
// Recognition — пороги распознавания.
type Recognition struct {
AutoConfidenceThreshold float64 `toml:"auto_confidence_threshold"`
}
// Telegram — настройки бота (Ф5).
type Telegram struct {
Enabled bool `toml:"enabled"`
Token string `toml:"token"`
AllowedUserIDs []int64 `toml:"allowed_user_ids"`
WebBaseURL string `toml:"web_base_url"` // для deep-link «открыть в вебе» (опц.)
Proxy string `toml:"proxy"` // опц. HTTP-прокси для api.telegram.org
}
// HTTP — параметры веб-сервера.
type HTTP struct {
Listen string `toml:"listen"`
// TrustedSubnets — allowlist подсетей. ПОКА НЕ ПРИМЕНЯЕТСЯ: деплой только
// в локальную сеть без доступа из интернета, поэтому middleware отложено
// (см. architecture.md). Поле сохранено под будущую реализацию.
TrustedSubnets []string `toml:"trusted_subnets"`
}
// Log — параметры логирования.
type Log struct {
Level string `toml:"level"`
Format string `toml:"format"`
}
// Duration — time.Duration, читаемый из TOML-строки вида "5s".
type Duration time.Duration
// UnmarshalText разбирает строку длительности (encoding.TextUnmarshaler).
func (d *Duration) UnmarshalText(text []byte) error {
v, err := time.ParseDuration(string(text))
if err != nil {
return err
}
*d = Duration(v)
return nil
}
// Std возвращает обычный time.Duration.
func (d Duration) Std() time.Duration { return time.Duration(d) }
// DisplayLocation возвращает таймзону отображения времени в веб-UI (пусто → UTC).
// Ошибка — если имя зоны не распознано; валидируется на старте (validate).
// Зоны доступны на любом хосте: база zoneinfo встроена в бинарь (time/tzdata),
// поэтому ошибка означает именно битое имя, а не отсутствие zoneinfo.
func (c *Config) DisplayLocation() (*time.Location, error) {
if c.General.Timezone == "" {
return time.UTC, nil
}
loc, err := time.LoadLocation(c.General.Timezone)
if err != nil {
return nil, fmt.Errorf("general.timezone %q: %w", c.General.Timezone, err)
}
return loc, nil
}
// ContentLanguage возвращает язык локализованного вывода абстрактным кодом
// ("ru" | "en"); пусто → "en". Диалект локали конкретного провайдера (ru-RU,
// en-US, …) выводит сам провайдер из этого кода — здесь его не знаем. Значение
// провалидировано на старте (validate): либо пусто, либо один из кодов.
func (c *Config) ContentLanguage() string {
if c.General.Language == "" {
return "en"
}
return c.General.Language
}
// Default возвращает конфиг с разумными умолчаниями; значения из файла
// перекрывают их при загрузке.
func Default() *Config {
return &Config{
General: General{Timezone: "UTC", Language: "en"},
QBittorrent: QBittorrent{
URL: "http://qbit:8989",
Username: "admin",
Category: "jellybit",
SavePath: "/srv/media/downloads",
},
Paths: Paths{
Downloads: "/srv/media/downloads",
Movies: "/srv/media/movies",
Series: "/srv/media/series",
},
Storage: Storage{DBPath: "/data/jellybit.db"},
LLM: LLM{
Type: "openai-compat",
Timeout: Duration(120 * time.Second),
MaxRetries: 3,
},
Metadata: Metadata{
TMDB: MetadataProvider{Timeout: Duration(10 * time.Second)},
TVDB: MetadataProvider{Timeout: Duration(10 * time.Second)},
},
Jellyfin: Jellyfin{Timeout: Duration(10 * time.Second)},
Worker: Worker{
PollInterval: Duration(5 * time.Second),
StuckAfter: Duration(time.Hour),
MagnetTimeout: Duration(24 * time.Hour),
CatchTimeout: Duration(10 * time.Minute),
SourceMissingThreshold: 3,
},
Recognition: Recognition{AutoConfidenceThreshold: 0.85},
HTTP: HTTP{Listen: ":8080"},
Log: Log{Level: "info", Format: "json"},
}
}
// Load читает и валидирует конфиг из path.
func Load(path string) (*Config, error) {
cfg := Default()
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config %q: %w", path, err)
}
if err := toml.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("parse config %q: %w", path, err)
}
if err := cfg.validate(); err != nil {
return nil, fmt.Errorf("invalid config %q: %w", path, err)
}
return cfg, nil
}
// validate — fail-fast проверка конфига на старте: обязательные поля заданы,
// медиа-пути доступны и не выходят из песочницы, диапазоны соблюдены, секреты
// включённых секций не пусты. Длительности уже провалидированы при разборе
// TOML (UnmarshalText). Лог об ошибке пишет граница (cmd/jellybit), не загрузчик.
func (c *Config) validate() error {
// Собираем все проблемы разом (errors.Join), чтобы оператор увидел все
// огрехи отрендеренного файла за один проход, а не правил их по одной.
var errs []error
// Обязательные поля ядра.
if c.QBittorrent.URL == "" {
errs = append(errs, errors.New("qbittorrent.url is empty"))
}
if c.HTTP.Listen == "" {
errs = append(errs, errors.New("http.listen is empty"))
}
if c.Storage.DBPath == "" {
errs = append(errs, errors.New("storage.db_path is empty"))
}
if c.LLM.Type != "openai-compat" {
errs = append(errs, fmt.Errorf("unsupported llm.type %q (supported: openai-compat)", c.LLM.Type))
}
// Таймзона отображения: имя должно распознаваться (zoneinfo встроен).
if _, err := c.DisplayLocation(); err != nil {
errs = append(errs, err)
}
// Язык локализованного вывода: пусто (→ en) или один из кодов. Fail-fast,
// как llm.type: мусорное значение не должно молча дефолтить. Множество
// {ru, en} — канон; при добавлении кода синхронно расширь мапперы
// metadata.tmdbLocale и recognize.languageDirective, иначе новый язык молча
// даст английский вывод.
switch c.General.Language {
case "", "ru", "en":
default:
errs = append(errs, fmt.Errorf("unsupported general.language %q (supported: ru, en)", c.General.Language))
}
// Медиа-пути песочницы: абсолютные, без traversal, существующие каталоги.
for _, p := range []struct{ name, path string }{
{"paths.downloads", c.Paths.Downloads},
{"paths.movies", c.Paths.Movies},
{"paths.series", c.Paths.Series},
} {
if err := validateMediaDir(p.name, p.path); err != nil {
errs = append(errs, err)
}
}
// Диапазоны.
if t := c.Recognition.AutoConfidenceThreshold; t < 0 || t > 1 {
errs = append(errs, fmt.Errorf("recognition.auto_confidence_threshold %.3f is out of range [0, 1]", t))
}
if c.LLM.MaxRetries < 0 {
errs = append(errs, fmt.Errorf("llm.max_retries %d must be >= 0", c.LLM.MaxRetries))
}
if c.Worker.SourceMissingThreshold < 1 {
errs = append(errs, fmt.Errorf("worker.source_missing_threshold %d must be >= 1", c.Worker.SourceMissingThreshold))
}
// qbittorrent.password намеренно не обязателен: qBittorrent может работать без
// аутентификации (например, обход авторизации для клиентов из доверенной
// подсети) — пустой пароль валиден.
// llm.api_key намеренно не обязателен: keyless-local LLM (LM Studio с
// заданным base_url, но без ключа) — валидный документированный дефолт.
// Консистентность опциональных секций: enabled ⇒ заданы нужные поля/секреты.
if c.Metadata.TMDB.Enabled && c.Metadata.TMDB.APIKey == "" {
errs = append(errs, errors.New("metadata.tmdb.enabled but metadata.tmdb.api_key is empty"))
}
if c.Metadata.TVDB.Enabled && c.Metadata.TVDB.APIKey == "" {
errs = append(errs, errors.New("metadata.tvdb.enabled but metadata.tvdb.api_key is empty"))
}
if c.Jellyfin.Enabled {
if c.Jellyfin.URL == "" {
errs = append(errs, errors.New("jellyfin.enabled but jellyfin.url is empty"))
}
if c.Jellyfin.APIKey == "" {
errs = append(errs, errors.New("jellyfin.enabled but jellyfin.api_key is empty (required secret)"))
}
}
if c.Telegram.Enabled && c.Telegram.Token == "" {
errs = append(errs, errors.New("telegram.enabled but telegram.token is empty (required secret)"))
}
return errors.Join(errs...)
}
// validateMediaDir проверяет путь медиа-песочницы: непустой, абсолютный, без
// traversal (filepath.Clean — без `..`/лишних разделителей) и указывает на
// существующий доступный каталог. Отдельного корня песочницы в конфиге нет,
// поэтому «строго под песочницей» обеспечиваем абсолютностью и отсутствием
// traversal; единый монтируемый корень (/srv/media) — забота деплоя.
func validateMediaDir(name, path string) error {
if path == "" {
return fmt.Errorf("%s is empty", name)
}
if !filepath.IsAbs(path) {
return fmt.Errorf("%s %q must be an absolute path", name, path)
}
if filepath.Clean(path) != path {
return fmt.Errorf("%s %q must be a clean path (no .. or redundant separators)", name, path)
}
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("%s %q is not accessible: %w", name, path, err)
}
if !info.IsDir() {
return fmt.Errorf("%s %q is not a directory", name, path)
}
return nil
}