// 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 }