Сверка с метабазой промахивалась на иностранных фильмах с русским релиз-именем (кейс «Тёмный рыцарь»): поиск шёл по одной строке provider_hint||title и игнорировал original_title, а базы индексированы прежде всего по оригинальным названиям. - matchMetadata ищет по ключам original_title → title → provider_hint с ранним стопом на первом единичном сильном матче; пустые и нормализованно-дублирующие ключи пропускаются, кандидаты для review копятся из всех заходов. - Промпт требует всегда заполнять title и original_title (дублировать при отсутствии оригинала / российском контенте; при неуверенности дублировать, не выдумывать). Разбор остаётся мягким к пустому original_title. - TMDB-поиск передаёт language (по умолчанию ru-RU, настраивается [metadata.tmdb].language); original_title не зависит от локали. - Нормализация названий сводит ё→е. Инварианты не ослаблены: авто только при подтверждённом единичном матче + структурной валидации + согласованности сигналов. Capability recognition впервые перенесена в OpenSpec; change архивирован. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
308 lines
12 KiB
Go
308 lines
12 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 {
|
||
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 не используется; 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) }
|
||
|
||
// 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), 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(30 * 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))
|
||
}
|
||
|
||
// Медиа-пути песочницы: абсолютные, без 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 — ядро, пароль нужен всегда.
|
||
if c.QBittorrent.Password == "" {
|
||
errs = append(errs, 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 == "" {
|
||
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
|
||
}
|