Добавил конвенции для конфигурации и сделал рефакторинг кода
This commit is contained in:
@@ -115,9 +115,14 @@ Module path — `git.vakhrushev.me/av/jellybit`. Go 1.26, `CGO_ENABLED=0`.
|
|||||||
|
|
||||||
- Раскладка: `cmd/jellybit` (точка входа) + `internal/<пакет>` по
|
- Раскладка: `cmd/jellybit` (точка входа) + `internal/<пакет>` по
|
||||||
компонентам из [architecture.md](docs/specs/architecture.md).
|
компонентам из [architecture.md](docs/specs/architecture.md).
|
||||||
- Ошибки оборачиваем с контекстом (`fmt.Errorf("...: %w", err)`).
|
- Ошибки — stdlib, обёртка с контекстом (`fmt.Errorf("...: %w", err)`),
|
||||||
|
проверка через `errors.Is`/`errors.As`, трансляция на внешней границе:
|
||||||
|
[docs/conventions/errors.md](docs/conventions/errors.md).
|
||||||
- Логирование только через `slog`, без `fmt.Println` — уровни, обязательные
|
- Логирование только через `slog`, без `fmt.Println` — уровни, обязательные
|
||||||
поля и что не логировать см. [docs/conventions/logging.md](docs/conventions/logging.md).
|
поля и что не логировать см. [docs/conventions/logging.md](docs/conventions/logging.md).
|
||||||
|
- Конфигурация — только TOML; секреты рендерит деплой (Ansible+Vault) в
|
||||||
|
файл (`config.toml` не коммитится, `0600`), не в env; валидация на старте:
|
||||||
|
[docs/conventions/config.md](docs/conventions/config.md).
|
||||||
- Время — всегда с явным TZ (сервер в `Europe/Moscow`).
|
- Время — всегда с явным TZ (сервер в `Europe/Moscow`).
|
||||||
|
|
||||||
Кросс-каттинг конвенции (как пишем код, а не что система делает) живут в
|
Кросс-каттинг конвенции (как пишем код, а не что система делает) живут в
|
||||||
|
|||||||
+3
-2
@@ -13,8 +13,9 @@ COPY jellybit /usr/local/bin/jellybit
|
|||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
# В distroless нет shell/curl — проверку делает сам бинарь (порт берёт из
|
# В distroless нет shell/curl — проверку делает сам бинарь (порт берёт из
|
||||||
# /config/config.toml — дефолтный путь). compose может переопределить параметры.
|
# конфига). Путь задаём явно: дефолт загрузчика — config.toml в рабочей
|
||||||
|
# директории, а конфиг смонтирован в /config. compose может переопределить.
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
CMD ["/usr/local/bin/jellybit", "healthcheck"]
|
CMD ["/usr/local/bin/jellybit", "healthcheck", "--config", "/config/config.toml"]
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/jellybit", "--config", "/config/config.toml"]
|
ENTRYPOINT ["/usr/local/bin/jellybit", "--config", "/config/config.toml"]
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
// нет shell/curl: docker зовёт сам бинарь.
|
// нет shell/curl: docker зовёт сам бинарь.
|
||||||
func runHealthcheck(args []string) error {
|
func runHealthcheck(args []string) error {
|
||||||
fs := flag.NewFlagSet("healthcheck", flag.ContinueOnError)
|
fs := flag.NewFlagSet("healthcheck", flag.ContinueOnError)
|
||||||
configPath := fs.String("config", "/config/config.toml", "путь к config.toml")
|
configPath := fs.String("config", config.DefaultPath, "путь к config.toml")
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,8 +29,21 @@ func serveHealthz(t *testing.T, status int) int {
|
|||||||
|
|
||||||
func writeConfig(t *testing.T, port int) string {
|
func writeConfig(t *testing.T, port int) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
path := filepath.Join(t.TempDir(), "config.toml")
|
dir := t.TempDir()
|
||||||
content := "[http]\nlisten = \"127.0.0.1:" + strconv.Itoa(port) + "\"\n"
|
// Медиа-пути должны существовать как каталоги (fail-fast валидация конфига).
|
||||||
|
for _, sub := range []string{"downloads", "movies", "series"} {
|
||||||
|
if err := os.MkdirAll(filepath.Join(dir, sub), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, "config.toml")
|
||||||
|
content := "" +
|
||||||
|
"[qbittorrent]\nurl = \"http://qbit:8080\"\npassword = \"secret\"\n\n" +
|
||||||
|
"[paths]\n" +
|
||||||
|
"downloads = \"" + filepath.Join(dir, "downloads") + "\"\n" +
|
||||||
|
"movies = \"" + filepath.Join(dir, "movies") + "\"\n" +
|
||||||
|
"series = \"" + filepath.Join(dir, "series") + "\"\n\n" +
|
||||||
|
"[http]\nlisten = \"127.0.0.1:" + strconv.Itoa(port) + "\"\n"
|
||||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import (
|
|||||||
// Только чтение: ни записи в БД, ни хардлинков.
|
// Только чтение: ни записи в БД, ни хардлинков.
|
||||||
func runRecognize(args []string) error {
|
func runRecognize(args []string) error {
|
||||||
fs := flag.NewFlagSet("recognize", flag.ContinueOnError)
|
fs := flag.NewFlagSet("recognize", flag.ContinueOnError)
|
||||||
configPath := fs.String("config", "/config/config.toml", "путь к config.toml")
|
configPath := fs.String("config", config.DefaultPath, "путь к config.toml")
|
||||||
dryRun := fs.Bool("dry-run", true, "только показать план, без изменений (единственный режим)")
|
dryRun := fs.Bool("dry-run", true, "только показать план, без изменений (единственный режим)")
|
||||||
contextStr := fs.String("context", "", "доп. текстовый контекст для распознавания")
|
contextStr := fs.String("context", "", "доп. текстовый контекст для распознавания")
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import (
|
|||||||
// воркер (фоном) → HTTP-сервер; останавливается по SIGINT/SIGTERM.
|
// воркер (фоном) → HTTP-сервер; останавливается по SIGINT/SIGTERM.
|
||||||
func runServe(args []string) error {
|
func runServe(args []string) error {
|
||||||
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
|
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
|
||||||
configPath := fs.String("config", "/config/config.toml", "путь к config.toml")
|
configPath := fs.String("config", config.DefaultPath, "путь к config.toml")
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-46
@@ -1,76 +1,82 @@
|
|||||||
# Пример конфигурации jellybit. Реальный config.toml не коммитится (содержит
|
# Пример конфигурации jellybit — единый справочник по всем секциям и полям.
|
||||||
# секреты). Для локального запуска: db_path -> ./jellybit.db.
|
# Реальный config.toml не коммитится (содержит секреты), заполняется деплоем.
|
||||||
|
# Секретные поля здесь оставлены пустыми. По умолчанию загрузчик ищет
|
||||||
|
# config.toml в рабочей директории; путь переопределяется опцией --config=path.
|
||||||
|
# Для локального запуска укажите существующие каталоги и db_path -> ./jellybit.db.
|
||||||
|
|
||||||
[qbittorrent]
|
[qbittorrent]
|
||||||
url = "http://qbit:8989" # по имени сервиса в общей docker-сети
|
url = "http://qbit:8989" # адрес qBittorrent WebUI; в docker-сети — по имени сервиса
|
||||||
username = "admin"
|
username = "admin" # логин WebUI
|
||||||
password = ""
|
password = "" # секрет: пароль WebUI; обязателен, заполняет деплой
|
||||||
category = "jellybit" # категория для добавляемых jellybit раздач (push)
|
category = "jellybit" # категория для добавляемых jellybit раздач (push, savepath)
|
||||||
tag = "jellybit" # тег для усыновления существующих раздач (pull, не двигает файлы)
|
tag = "jellybit" # тег для усыновления существующих раздач (pull, не двигает файлы)
|
||||||
savepath = "/srv/media/downloads" # qBit кладёт загрузки сюда (задаём при добавлении)
|
savepath = "/srv/media/downloads" # куда qBittorrent кладёт загрузки (задаём при добавлении)
|
||||||
path_map = {} # фолбэк: префикс save_path → хост-префикс, напр. {"/data" = "/srv/media"}; обычно пуст
|
path_map = {} # фолбэк: префикс save_path → хост-префикс, напр. {"/data" = "/srv/media"}; обычно пуст
|
||||||
|
|
||||||
[paths]
|
[paths]
|
||||||
downloads = "/srv/media/downloads"
|
# Медиа-песочница на хосте. Каждый путь: абсолютный, без traversal (`..`) и
|
||||||
movies = "/srv/media/movies"
|
# должен существовать как доступный каталог (проверяется на старте). Целевые
|
||||||
series = "/srv/media/series"
|
# movies/series и источник downloads монтируются под единый корень (/srv/media).
|
||||||
|
downloads = "/srv/media/downloads" # источник: где лежат загрузки qBittorrent (только читаем/линкуем)
|
||||||
|
movies = "/srv/media/movies" # целевой каталог фильмов для Jellyfin (раскладка хардлинками)
|
||||||
|
series = "/srv/media/series" # целевой каталог сериалов для Jellyfin (раскладка хардлинками)
|
||||||
|
|
||||||
[storage]
|
[storage]
|
||||||
db_path = "/data/jellybit.db" # SQLite на persistent-томе
|
db_path = "/data/jellybit.db" # путь к файлу SQLite на persistent-томе; обязателен
|
||||||
|
|
||||||
[llm]
|
[llm]
|
||||||
type = "openai-compat"
|
type = "openai-compat" # провайдер распознавания; допустимо: openai-compat
|
||||||
# LLM на хосте (LM Studio) из bridged-контейнера — через host.docker.internal.
|
# LLM на хосте (LM Studio) из bridged-контейнера — через host.docker.internal.
|
||||||
base_url = "http://host.docker.internal:1234/v1"
|
base_url = "http://host.docker.internal:1234/v1" # эндпоинт LLM; пусто = распознавание выключено
|
||||||
api_key = ""
|
api_key = "" # секрет: ключ LLM; обязателен, если задан base_url (заполняет деплой)
|
||||||
model = "qwen2.5-32b-instruct"
|
model = "qwen2.5-32b-instruct" # имя модели на эндпоинте
|
||||||
proxy = "" # опц. HTTP-прокси для удалённых эндпоинтов
|
proxy = "" # опц. HTTP-прокси для удалённых эндпоинтов; пусто = без прокси
|
||||||
timeout = "120s"
|
timeout = "120s" # таймаут запроса к LLM; Go-duration (s/m/h)
|
||||||
max_retries = 3
|
max_retries = 3 # попыток получить валидный ответ LLM; целое ≥ 0
|
||||||
|
|
||||||
[metadata.tmdb]
|
[metadata.tmdb]
|
||||||
enabled = false # включается ключом; без матча авто не делаем
|
enabled = false # включить провайдера TMDB; без матча авто-раскладку не делаем
|
||||||
api_key = ""
|
api_key = "" # секрет: ключ TMDB; обязателен, если enabled (заполняет деплой)
|
||||||
proxy = ""
|
proxy = "" # опц. HTTP-прокси; пусто = без прокси
|
||||||
timeout = "10s"
|
timeout = "10s" # таймаут запроса к TMDB; Go-duration (s/m/h)
|
||||||
|
|
||||||
[metadata.tvdb]
|
[metadata.tvdb]
|
||||||
enabled = false
|
enabled = false # включить провайдера TVDB
|
||||||
api_key = ""
|
api_key = "" # секрет: ключ TVDB; обязателен, если enabled (заполняет деплой)
|
||||||
proxy = ""
|
proxy = "" # опц. HTTP-прокси; пусто = без прокси
|
||||||
timeout = "10s"
|
timeout = "10s" # таймаут запроса к TVDB; Go-duration (s/m/h)
|
||||||
|
|
||||||
[metadata.tvmaze]
|
[metadata.tvmaze]
|
||||||
enabled = false # без ключа; только сериалы, тег [tvdbid-…] из externals
|
enabled = false # включить провайдера TVMaze; без ключа, только сериалы (тег [tvdbid-…] из externals)
|
||||||
proxy = ""
|
proxy = "" # опц. HTTP-прокси; пусто = без прокси
|
||||||
timeout = "10s"
|
timeout = "10s" # таймаут запроса к TVMaze; Go-duration (s/m/h)
|
||||||
|
|
||||||
[jellyfin]
|
[jellyfin]
|
||||||
enabled = false # включить пересканирование медиатеки после раскладки
|
enabled = false # включить пересканирование медиатеки после раскладки
|
||||||
url = "http://jellyfin:8096" # по имени сервиса в общей docker-сети
|
url = "http://jellyfin:8096" # адрес Jellyfin; обязателен, если enabled (в docker-сети — по имени сервиса)
|
||||||
api_key = "" # API-ключ Jellyfin (Dashboard → API Keys)
|
api_key = "" # секрет: API-ключ Jellyfin (Dashboard → API Keys); обязателен, если enabled
|
||||||
proxy = "" # опц. HTTP-прокси
|
proxy = "" # опц. HTTP-прокси; пусто = без прокси
|
||||||
timeout = "10s"
|
timeout = "10s" # таймаут запроса к Jellyfin; Go-duration (s/m/h)
|
||||||
|
|
||||||
[worker]
|
[worker]
|
||||||
poll_interval = "5s"
|
poll_interval = "5s" # как часто опрашивать qBittorrent; Go-duration (s/m/h)
|
||||||
stuck_after = "1h"
|
stuck_after = "1h" # сколько ждать прогресса, прежде чем счесть раздачу зависшей; Go-duration
|
||||||
magnet_timeout = "30m"
|
magnet_timeout = "30m" # ждать метаданные magnet не дольше; Go-duration
|
||||||
|
|
||||||
[recognition]
|
[recognition]
|
||||||
auto_confidence_threshold = 0.85
|
auto_confidence_threshold = 0.85 # порог авто-раскладки без ревью; доля 0.0–1.0
|
||||||
|
|
||||||
[telegram]
|
[telegram]
|
||||||
enabled = false
|
enabled = false # включить Telegram-бота
|
||||||
token = ""
|
token = "" # секрет: токен бота; обязателен, если enabled (заполняет деплой)
|
||||||
allowed_user_ids = [] # пусто = запрет всем (fail-closed)
|
allowed_user_ids = [] # allowlist Telegram user id (целые); пусто = запрет всем (fail-closed)
|
||||||
web_base_url = "" # напр. "http://jellybit:8080" — для кнопки «открыть в вебе»
|
web_base_url = "" # база для deep-link «открыть в вебе», напр. "http://jellybit:8080"; пусто = без кнопки
|
||||||
proxy = "" # опц. HTTP-прокси для api.telegram.org
|
proxy = "" # опц. HTTP-прокси для api.telegram.org; пусто = без прокси
|
||||||
|
|
||||||
[http]
|
[http]
|
||||||
listen = ":8080"
|
listen = ":8080" # адрес прослушивания HTTP-сервера; формат [host]:port
|
||||||
trusted_subnets = [] # ПОКА НЕ ПРИМЕНЯЕТСЯ (деплой только в LAN); зарезервировано
|
trusted_subnets = [] # allowlist подсетей (CIDR); ПОКА НЕ ПРИМЕНЯЕТСЯ (деплой только в LAN), зарезервировано
|
||||||
|
|
||||||
[log]
|
[log]
|
||||||
level = "info"
|
level = "info" # уровень логирования; одно из: debug, info, warn, error
|
||||||
format = "json"
|
format = "json" # формат логов; одно из: json, text
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# Конфигурация
|
||||||
|
|
||||||
|
Конвенция: *как* устроена и грузится конфигурация jellybit (TOML).
|
||||||
|
Правила оформления кода (How), не спецификация поведения.
|
||||||
|
|
||||||
|
Краткая выжимка и инварианты — в [CLAUDE.md](../../CLAUDE.md), раздел
|
||||||
|
«Конвенции кода».
|
||||||
|
|
||||||
|
> Каркас. Загрузчик `internal/config/config.go` уже грузит TOML; валидация
|
||||||
|
> на старте — в работе (`TODO`), обкатывается на следующем шаге.
|
||||||
|
|
||||||
|
## Принципы
|
||||||
|
|
||||||
|
- **Конфигурация — только TOML.** Env-переменные для конфига **не
|
||||||
|
используем**: окружение наследуется дочерними процессами и видно через
|
||||||
|
`/proc/<pid>/environ` — для секретов это слабее файла под `0600`.
|
||||||
|
- Грузим **один раз при старте** в одну типизированную структуру `Config`
|
||||||
|
(под-структуры по секциям). Дальше по коду читаем только её — никаких
|
||||||
|
`os.Getenv`/чтения файла в бизнес-коде, только загрузчик `internal/config`.
|
||||||
|
- Конфиг **неизменяем** после старта; смена параметров — рестарт процесса.
|
||||||
|
|
||||||
|
## Файл и поиск
|
||||||
|
|
||||||
|
- Имя конфига по умолчанию — **`config.toml`**, ищется в **рабочей
|
||||||
|
директории** процесса.
|
||||||
|
- Путь переопределяется опцией **`--config=path`**.
|
||||||
|
- Образец в репозитории — **`config.example.toml`** (см. ниже); реальный
|
||||||
|
`config.toml` не коммитится.
|
||||||
|
|
||||||
|
## config.example.toml — самодокументируемый образец
|
||||||
|
|
||||||
|
`config.example.toml` коммитим как единый справочник по конфигу: все секции
|
||||||
|
и все поля. **Каждое поле снабжаем комментарием**, из которого ясно:
|
||||||
|
|
||||||
|
- **зачем** поле — что оно меняет в поведении;
|
||||||
|
- **диапазон/допустимые значения** — перечисление или границы;
|
||||||
|
- **единицы измерения**, если применимо — секунды/миллисекунды, байты/КБ,
|
||||||
|
доля `0–1` и т.п.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[worker]
|
||||||
|
poll_interval = "5s" # как часто опрашивать qBittorrent; Go-duration (s/m/h)
|
||||||
|
magnet_timeout = "30m" # ждать метаданные magnet не дольше; Go-duration
|
||||||
|
|
||||||
|
[recognition]
|
||||||
|
auto_confidence_threshold = 0.85 # порог авто-раскладки без ревью; доля 0.0–1.0
|
||||||
|
|
||||||
|
[llm]
|
||||||
|
max_retries = 3 # попыток получить валидный ответ LLM; целое ≥ 0
|
||||||
|
```
|
||||||
|
|
||||||
|
Секретные поля оставляем пустыми — значение приходит из деплоя (см.
|
||||||
|
«Секреты»).
|
||||||
|
|
||||||
|
## Поля по дискриминатору `type`
|
||||||
|
|
||||||
|
Когда набор полей секции зависит от поля-дискриминатора `type` (выбор одного
|
||||||
|
из бекендов/внешних сервисов — напр. `[llm].type`), обязательность и
|
||||||
|
опциональность полей определяются значением `type`, а не фиксированы для
|
||||||
|
секции.
|
||||||
|
|
||||||
|
- **Валидация — по `type`.** Для каждого поддерживаемого `type` свой набор
|
||||||
|
обязательных полей; поля, относящиеся к другим `type`, не требуются.
|
||||||
|
Неизвестный `type` → ошибка на старте с перечислением поддерживаемых.
|
||||||
|
- **Образец — по `type`.** В `config.example.toml`:
|
||||||
|
- основной (дефолтный) `type` **предзаполнен** рабочими значениями;
|
||||||
|
- альтернативные `type` — **блоками-комментариями ниже**, каждый со своим
|
||||||
|
описанием полей (зачем/диапазон/единицы — как у обычных полей);
|
||||||
|
- так из примера видны все варианты и поля каждого, не открывая код.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[llm]
|
||||||
|
type = "openai-compat" # бекенд LLM; варианты ниже
|
||||||
|
base_url = "http://host.docker.internal:1234/v1" # эндпоинт OpenAI-совместимого API
|
||||||
|
api_key = "" # ключ; пусто для keyless-local (LM Studio)
|
||||||
|
model = "qwen2.5-32b-instruct" # имя модели у провайдера
|
||||||
|
|
||||||
|
# --- альтернативный бекенд: type = "<other>" ---
|
||||||
|
# [llm]
|
||||||
|
# type = "<other>" # описание варианта
|
||||||
|
# ... # его обязательные/опциональные поля
|
||||||
|
```
|
||||||
|
|
||||||
|
## Секреты
|
||||||
|
|
||||||
|
Секреты доставляет **деплой**, рендеря их прямо в `config.toml` (jellybit:
|
||||||
|
Ansible + Vault). Приложение просто читает TOML — отдельного слоя секретов
|
||||||
|
в коде нет. Источник истины секрета — внешнее хранилище деплоя (Vault), не
|
||||||
|
репозиторий и не env.
|
||||||
|
|
||||||
|
- Секретные поля jellybit: `qbittorrent.password`, `llm.api_key`,
|
||||||
|
`metadata.*.api_key`, `jellyfin.api_key`, `telegram.token`.
|
||||||
|
- Рендеренный `config.toml` (с секретами) **не коммитится**; права `0600`,
|
||||||
|
владелец — runtime-пользователь (`1000:1000`).
|
||||||
|
- В `config.example.toml` секретные поля — пустые строки.
|
||||||
|
- Загрузчик на старте проверяет, что обязательные секреты не пусты (ловит
|
||||||
|
криво отрендеренный файл) — см. «Валидация и fail-fast».
|
||||||
|
- В логи секреты не попадают — см. [logging.md](logging.md), «Безопасность».
|
||||||
|
|
||||||
|
## Валидация и fail-fast
|
||||||
|
|
||||||
|
Конфиг валидируем **на старте, до приёма трафика**. Невалидный конфиг —
|
||||||
|
лог `ERROR` и выход с ненулевым кодом (не стартуем «наполовину»).
|
||||||
|
|
||||||
|
Что проверяем (jellybit):
|
||||||
|
|
||||||
|
- обязательные поля заданы (напр. `qbittorrent.url`, `paths.*`,
|
||||||
|
`storage.db_path`);
|
||||||
|
- пути `paths.movies`/`series`/`downloads` существуют и доступны; целевые —
|
||||||
|
под единой песочницей (см. инварианты в [CLAUDE.md](../../CLAUDE.md));
|
||||||
|
- диапазоны: `recognition.auto_confidence_threshold` ∈ [0, 1],
|
||||||
|
`llm.max_retries` ≥ 0;
|
||||||
|
- длительности парсятся (`llm.timeout`, `worker.poll_interval`, …);
|
||||||
|
- включённые секции консистентны: `metadata.tmdb.enabled` → задан `api_key`;
|
||||||
|
`jellyfin.enabled` → заданы `url`+`api_key`; `telegram.enabled` → `token`.
|
||||||
|
|
||||||
|
## Структура в коде
|
||||||
|
|
||||||
|
- Весь разбор и валидация — в `internal/config`; наружу отдаётся готовая
|
||||||
|
`Config`.
|
||||||
|
- Одна корневая структура `Config` с под-структурами по секциям
|
||||||
|
(`QBittorrent`, `Paths`, `LLM`, `Metadata`, `Jellyfin`, `Worker`,
|
||||||
|
`Recognition`, `Telegram`, `HTTP`, `Log`).
|
||||||
@@ -5,11 +5,16 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/pelletier/go-toml/v2"
|
"github.com/pelletier/go-toml/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// DefaultPath — имя конфига по умолчанию: ищется в рабочей директории
|
||||||
|
// процесса. Переопределяется опцией --config=path.
|
||||||
|
const DefaultPath = "config.toml"
|
||||||
|
|
||||||
// Config — корневая конфигурация сервиса (см. config.example.toml).
|
// Config — корневая конфигурация сервиса (см. config.example.toml).
|
||||||
type Config struct {
|
type Config struct {
|
||||||
QBittorrent QBittorrent `toml:"qbittorrent"`
|
QBittorrent QBittorrent `toml:"qbittorrent"`
|
||||||
@@ -195,7 +200,15 @@ func Load(path string) (*Config, error) {
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validate — fail-fast проверка конфига на старте: обязательные поля заданы,
|
||||||
|
// медиа-пути доступны и не выходят из песочницы, диапазоны соблюдены, секреты
|
||||||
|
// включённых секций не пусты. Длительности уже провалидированы при разборе
|
||||||
|
// TOML (UnmarshalText). Лог об ошибке пишет граница (cmd/jellybit), не загрузчик.
|
||||||
func (c *Config) validate() error {
|
func (c *Config) validate() error {
|
||||||
|
// Обязательные поля ядра.
|
||||||
|
if c.QBittorrent.URL == "" {
|
||||||
|
return errors.New("qbittorrent.url is empty")
|
||||||
|
}
|
||||||
if c.HTTP.Listen == "" {
|
if c.HTTP.Listen == "" {
|
||||||
return errors.New("http.listen is empty")
|
return errors.New("http.listen is empty")
|
||||||
}
|
}
|
||||||
@@ -205,5 +218,76 @@ func (c *Config) validate() error {
|
|||||||
if c.LLM.Type != "openai-compat" {
|
if c.LLM.Type != "openai-compat" {
|
||||||
return fmt.Errorf("unsupported llm.type %q (supported: openai-compat)", c.LLM.Type)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// validCfg возвращает минимально валидный конфиг поверх Default() с медиа-путями
|
||||||
|
// во временном каталоге (существуют как директории).
|
||||||
|
func validCfg(t *testing.T) *Config {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
c := Default()
|
||||||
|
c.QBittorrent.Password = "secret"
|
||||||
|
c.Paths.Downloads = filepath.Join(dir, "downloads")
|
||||||
|
c.Paths.Movies = filepath.Join(dir, "movies")
|
||||||
|
c.Paths.Series = filepath.Join(dir, "series")
|
||||||
|
for _, p := range []string{c.Paths.Downloads, c.Paths.Movies, c.Paths.Series} {
|
||||||
|
if err := os.MkdirAll(p, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir %s: %v", p, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// LLM по умолчанию без base_url — секция выключена, api_key не требуется.
|
||||||
|
c.LLM.BaseURL = ""
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_OK(t *testing.T) {
|
||||||
|
if err := validCfg(t).validate(); err != nil {
|
||||||
|
t.Fatalf("ожидался валидный конфиг, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidate_KeylessLocalLLM — keyless-local LLM (задан base_url, пустой
|
||||||
|
// api_key, напр. LM Studio) — валиден: ключ не обязателен.
|
||||||
|
func TestValidate_KeylessLocalLLM(t *testing.T) {
|
||||||
|
c := validCfg(t)
|
||||||
|
c.LLM.BaseURL = "http://host.docker.internal:1234/v1"
|
||||||
|
c.LLM.APIKey = ""
|
||||||
|
if err := c.validate(); err != nil {
|
||||||
|
t.Fatalf("keyless-local LLM должен быть валиден, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_Errors(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
mutate func(*Config)
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"empty qbittorrent.url", func(c *Config) { c.QBittorrent.URL = "" }, "qbittorrent.url"},
|
||||||
|
{"empty qbittorrent.password", func(c *Config) { c.QBittorrent.Password = "" }, "qbittorrent.password"},
|
||||||
|
{"empty db_path", func(c *Config) { c.Storage.DBPath = "" }, "storage.db_path"},
|
||||||
|
{"bad llm.type", func(c *Config) { c.LLM.Type = "anthropic" }, "llm.type"},
|
||||||
|
{"relative movies", func(c *Config) { c.Paths.Movies = "movies" }, "absolute"},
|
||||||
|
{"traversal series", func(c *Config) { c.Paths.Series = c.Paths.Series + "/../x" }, "clean"},
|
||||||
|
{"missing downloads", func(c *Config) { c.Paths.Downloads = "/no/such/dir/jellybit" }, "not accessible"},
|
||||||
|
{"threshold high", func(c *Config) { c.Recognition.AutoConfidenceThreshold = 1.5 }, "auto_confidence_threshold"},
|
||||||
|
{"negative retries", func(c *Config) { c.LLM.MaxRetries = -1 }, "max_retries"},
|
||||||
|
{"tmdb enabled no key", func(c *Config) { c.Metadata.TMDB.Enabled = true }, "metadata.tmdb"},
|
||||||
|
{"tvdb enabled no key", func(c *Config) { c.Metadata.TVDB.Enabled = true }, "metadata.tvdb"},
|
||||||
|
{"jellyfin enabled no url", func(c *Config) { c.Jellyfin.Enabled = true; c.Jellyfin.URL = "" }, "jellyfin.url"},
|
||||||
|
{"jellyfin enabled no key", func(c *Config) { c.Jellyfin.Enabled = true; c.Jellyfin.URL = "http://j"; c.Jellyfin.APIKey = "" }, "jellyfin.api_key"},
|
||||||
|
{"telegram enabled no token", func(c *Config) { c.Telegram.Enabled = true }, "telegram.token"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
c := validCfg(t)
|
||||||
|
tc.mutate(c)
|
||||||
|
err := c.validate()
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||||
|
t.Fatalf("ожидалась ошибка про %q, got %v", tc.want, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,7 +34,13 @@ context: |
|
|||||||
Детали: уровни, обязательные поля — docs/conventions/logging.md.
|
Детали: уровни, обязательные поля — docs/conventions/logging.md.
|
||||||
- Безопасность: никаких секретов в полях логов (пароли qBittorrent,
|
- Безопасность: никаких секретов в полях логов (пароли qBittorrent,
|
||||||
API-ключи LLM/метабаз, auth-заголовки).
|
API-ключи LLM/метабаз, auth-заголовки).
|
||||||
- Ошибки оборачиваем с контекстом (fmt.Errorf("...: %w", err)).
|
- Конфигурация — только TOML; секреты рендерит деплой (Ansible+Vault) в
|
||||||
|
файл (config.toml не коммитится, 0600), env для конфига не используем;
|
||||||
|
валидация на старте. Детали: docs/conventions/config.md.
|
||||||
|
- Ошибки — stdlib, обёртка с контекстом (fmt.Errorf("...: %w", err)),
|
||||||
|
проверка errors.Is/errors.As, трансляция доменной ошибки в ответ на
|
||||||
|
внешней границе (наружу не отдаём текст внутренней ошибки). Детали:
|
||||||
|
docs/conventions/errors.md.
|
||||||
- Время — всегда с явным TZ (сервер в Europe/Moscow; логи — в UTC).
|
- Время — всегда с явным TZ (сервер в Europe/Moscow; логи — в UTC).
|
||||||
|
|
||||||
# Project context (optional)
|
# Project context (optional)
|
||||||
|
|||||||
Reference in New Issue
Block a user