- Раскладка docs/ приведена к канону 2: заведены passport/architecture/ database/security/review и research; docs/specs, drafts, backlog, review/ и BRIEF.md разобраны и удалены, беклог переехал в docs/tasks (34 задачи, 6 целей, слаги на английский). - Нарративы specs удалены как дубли openspec-спек после поимённой сверки; остаток заведён задачами (редактор маппинга ревью, крайние случаи именования), отказ от сущности title промоутнут в ADR. - Проектные копии агентов и скиллов ревью/пайплайна удалены в пользу плагинов av-dev-pm и av-dev-pipeline; в task gate добавлен шаг canon вместо er-schema.
368 lines
16 KiB
Go
368 lines
16 KiB
Go
// 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/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
|
||
}
|