// 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 { 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"` } // 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 не используется. 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"` } // 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) } // Default возвращает конфиг с разумными умолчаниями; значения из файла // перекрывают их при загрузке. func Default() *Config { return &Config{ 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(30 * time.Minute), }, 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 { // Обязательные поля ядра. if c.QBittorrent.URL == "" { return errors.New("qbittorrent.url is empty") } if c.HTTP.Listen == "" { return errors.New("http.listen is empty") } if c.Storage.DBPath == "" { return errors.New("storage.db_path is empty") } if c.LLM.Type != "openai-compat" { return fmt.Errorf("unsupported llm.type %q (supported: openai-compat)", c.LLM.Type) } // Медиа-пути песочницы: абсолютные, без 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 { return err } } // Диапазоны. if t := c.Recognition.AutoConfidenceThreshold; t < 0 || t > 1 { return fmt.Errorf("recognition.auto_confidence_threshold %.3f is out of range [0, 1]", t) } if c.LLM.MaxRetries < 0 { return fmt.Errorf("llm.max_retries %d must be >= 0", c.LLM.MaxRetries) } // Обязательные секреты включённых секций (ловит криво отрендеренный деплоем // файл). qBittorrent — ядро, пароль нужен всегда. if c.QBittorrent.Password == "" { return errors.New("qbittorrent.password is empty (required secret)") } // llm.api_key намеренно не обязателен: keyless-local LLM (LM Studio с // заданным base_url, но без ключа) — валидный документированный дефолт. // Консистентность опциональных секций: enabled ⇒ заданы нужные поля/секреты. if c.Metadata.TMDB.Enabled && c.Metadata.TMDB.APIKey == "" { return errors.New("metadata.tmdb.enabled but metadata.tmdb.api_key is empty") } if c.Metadata.TVDB.Enabled && c.Metadata.TVDB.APIKey == "" { return errors.New("metadata.tvdb.enabled but metadata.tvdb.api_key is empty") } if c.Jellyfin.Enabled { if c.Jellyfin.URL == "" { return errors.New("jellyfin.enabled but jellyfin.url is empty") } if c.Jellyfin.APIKey == "" { return errors.New("jellyfin.enabled but jellyfin.api_key is empty (required secret)") } } if c.Telegram.Enabled && c.Telegram.Token == "" { return errors.New("telegram.enabled but telegram.token is empty (required secret)") } return nil } // 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 }