Files
jellybit/internal/config/config.go
T
avandClaude Opus 4.8 5d5456fa68 Хранение времени: RFC 3339 (UTC) + таймзона отображения в конфиге
Метки времени в SQLite переведены с формата datetime('now')
(«2006-01-02 15:04:05») на RFC 3339 всегда-UTC («2006-01-02T15:04:05Z»):
самоописываемое хранилище (зона в значении), валидный ISO 8601, единый
формат с логами. Фиксированная ширина сохраняет лексикографическую
сортировку TEXT = хронологию (COALESCE(source_added_at, created_at)).

- Единая точка генерации времени в Go: store.Now()/FormatTime; DEFAULT
  (datetime('now')) снят со всех колонок — время всегда пишет приложение
  (зеркально ident.NewID для id), fail-loud при забытой вставке (NOT NULL).
  Все INSERT-сайты в store передают created_at/updated_at явно.
- Миграция 0008 (rebuild 7 таблиц без DEFAULT + backfill strftime, FK/PK/
  индексы сохранены байт-в-байт по образцу 0006); симметричная down.
- Новая секция конфига [general] с полем timezone (дефолт UTC) — зона
  ОТОБРАЖЕНИЯ в веб-UI; хранение остаётся UTC. Жёсткая валидация зоны на
  старте; zoneinfo встроен (time/tzdata), заменён зашитый Europe/Moscow.
- Тесты: round-trip миграции (up/down, NULL source_added_at), валидация
  зоны, сдвиг даты по зоне; обновлены фикстуры и TestUlidMigration.
- Docs: конвенции database/config, ER-схема; спека web-ui (таймзона).

OpenSpec change time-storage-rfc3339 (заархивирован).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 11:32:07 +03:00

336 lines
14 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"`
}
// 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 не используется; language учитывает только TMDB
// (локаль возвращаемых названий, дефолт ru-RU).
type MetadataProvider struct {
Enabled bool `toml:"enabled"`
APIKey string `toml:"api_key"`
Proxy string `toml:"proxy"`
Timeout Duration `toml:"timeout"`
Language string `toml:"language"`
}
// 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"`
// 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
}
// Default возвращает конфиг с разумными умолчаниями; значения из файла
// перекрывают их при загрузке.
func Default() *Config {
return &Config{
General: General{Timezone: "UTC"},
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), Language: "ru-RU"},
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),
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)
}
// Медиа-пути песочницы: абсолютные, без 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
}