Files
jellybit/internal/config/config.go
T

294 lines
11 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 {
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
}