Правки после ревью документации

This commit is contained in:
2026-06-13 21:47:30 +03:00
parent 547940ea59
commit 34bd2a5b5f
10 changed files with 419 additions and 162 deletions
+195 -87
View File
@@ -11,109 +11,157 @@ qBittorrent, определяет содержимое (фильм или сер
- **Один статический бинарь.** Доставка — копированием на сервер. См.
[ADR-2026-06-13-go-single-binary](../adr/ADR-2026-06-13-go-single-binary.md).
- **Источник не трогаем.** В библиотеку кладём хардлинки; qBittorrent
продолжает раздачу, место на диске не дублируется.
- **Единое ядро, тонкие транспорты.** Вся логика приёма загрузки — в
use-case `Ingest`. HTTP API, веб-UI и Telegram — обёртки над ним.
- **Источник неприкосновенен** (жёсткий инвариант). jellybit делает
только `mkdir`, `link(2)` и `unlink` *своих* целевых ссылок (для undo).
Никогда не `unlink`/`rename` под `paths.downloads`. См.
[ADR-2026-06-13-hardlinks](../adr/ADR-2026-06-13-hardlinks.md).
- **Выход распознавания недоверенный.** Имена файлов, контекст и
сообщение бота управляются извне. Целевой путь всегда санитизируется и
проверяется, что он строго под `paths.movies`/`paths.series` (см.
«Раскладка файлов»). Безопасность держится на валидации, не на промпте.
- **Единое ядро, тонкие транспорты.** Логика приёма — в use-case
`Ingest`; переходы состояний принадлежат `worker`. HTTP API, веб-UI и
Telegram складывают команды, `worker` их сериализует.
- **Опциональные внешние зависимости.** Базы метаданных (TMDB/TVDB)
включаются конфигом; без них сервис работает на одном LLM.
включаются конфигом; без них сервис работает на одном LLM, но
авто-раскладка без матча в базе не делается (см. recognition.md).
- **Минимум компонентов.** В духе umbar — без лишних сервисов.
## Компоненты
| Пакет | Ответственность |
| ----------- | ----------------------------------------------------- |
| `ingest` | use-case приёма загрузки, общий для всех транспортов |
| `qbt` | клиент qBittorrent WebUI API |
| `worker` | фоновый цикл: машина состояний, поллинг завершения |
| `recognize` | пред-парс имени + вызов LLM + модель уверенности |
| `llm` | провайдер LLM за интерфейсом (дискриминатор `type`) |
| `metadata` | интерфейс баз метаданных + TMDB/TVDB (опц.) |
| `layout` | конвенции Jellyfin + хардлинкер |
| `store` | SQLite: загрузки, распознавание, ссылки |
| `httpapi` | REST + веб-UI (server-rendered, htmx) |
| `tgbot` | Telegram-адаптер + парсер сообщений торрент-бота |
| `config` | загрузка TOML-конфига |
| Пакет | Ответственность |
| ----------- | -------------------------------------------------------- |
| `ingest` | use-case приёма загрузки, общий для всех транспортов |
| `qbt` | клиент qBittorrent WebUI API (сессия, добавление, опрос) |
| `worker` | владелец машины состояний; поллинг, сериализация команд |
| `recognize` | пред-парс имени + вызов LLM + модель уверенности |
| `llm` | провайдер LLM за интерфейсом (дискриминатор `type`) |
| `metadata` | интерфейс баз метаданных + TMDB/TVDB (опц.) |
| `layout` | конвенции Jellyfin, санитизация путей, хардлинкер, undo |
| `store` | SQLite: загрузки, распознавание, подсказки, ссылки |
| `httpapi` | REST + веб-UI (server-rendered, htmx) |
| `tgbot` | Telegram: приём + парсер сообщений бота + исходящие пинги |
| `config` | загрузка TOML-конфига |
## Поток и машина состояний
```
ingest → downloading → completed → recognizing ─┬─ уверенно ───────→ linking → done
└─ сомнительно → review → linking → done
любой шаг при ошибке → failed
ingest → downloading → completed → recognizing ─┬─ авто ────────────────→ linking → done
└─ review ⇄ recognizing ─→ linking → done
│ │ └─ moving/checking (ещё не готов)
│ └─ stuck (не качается дольше таймаута)
└─ failed ⇄ retry
done → undo → reverted
review → «Позже» → deferred → review
любой → «Отклонить» → cancelled
```
- **ingest** — приняли источник + контекст, поставили в qBittorrent
(категория `jellybit`), записали в БД.
- **ingest** — приняли источник + контекст, отдали в qBittorrent
(категория `jellybit`), записали в БД с ключом идемпотентности.
- **downloading / completed** — `worker` поллит qBittorrent по категории
(интервал `worker.poll_interval`, по умолчанию 5 с).
- **recognizing** — `recognize` строит план раскладки и оценку
уверенности (см. [recognition.md](recognition.md)).
- **review** — план уходит человеку (веб-UI / Telegram), ждём решения;
сценарии — в [review-ux.md](review-ux.md).
- **linking** — `layout` создаёт хардлинки в библиотеке.
- **done** — опционально дёргаем скан библиотеки Jellyfin.
(`worker.poll_interval`, 5 с). Готовность — только когда файлы на месте
(не `moving`/`checking*`), см. «Завершение в qBittorrent».
- **recognizing** — `recognize` строит план и оценку уверенности
([recognition.md](recognition.md)). Невалидный/непарсящийся ответ LLM →
review (не failed).
- **review** — план уходит человеку ([review-ux.md](review-ux.md)); цикл
`review ⇄ recognizing` — перераспознавание по подсказке.
- **linking** — `layout` создаёт хардлинки; идемпотентно, батчем.
- **done** — опционально дёргаем скан Jellyfin; доступен **undo**
`reverted` (убрать созданные ссылки).
- **deferred / cancelled / failed / stuck** — «Позже», «Отклонить»,
ошибка (ретраибельна), не качается дольше таймаута.
Состояние персистентно в SQLite — перезапуск сервиса безопасен, `worker`
продолжает с того же места.
Все переходы и команды идут через `worker` под per-download блокировкой —
два транспорта не гонятся за одно состояние. Состояние персистентно в
SQLite; на старте `worker` сверяет категорию qBittorrent с БД и
продолжает.
## Транспорты
Все три ведут в один `Ingest(req)`:
Все ведут в один `Ingest(req)`; действия пользователя (apply / refine /
reject / defer / undo) — команды к `worker`:
- **HTTP API + веб-UI** — форма «добавить», список загрузок, экран
подтверждения раскладки (server-rendered + htmx, без JS-сборки).
- **Telegram-бот** — переслать magnet или сообщение торрент-бота прямо в
jellybit; текст становится контекстом распознавания.
- **HTTP API + веб-UI** — форма «добавить», список, экран ревью
(server-rendered + htmx). В v1 **без авторизации** (доверенная LAN), с
опциональным allowlist подсетей (`http.trusted_subnets`). Защиту
навесим позже — [drafts/ideas.md](../drafts/ideas.md).
- **Telegram-бот** — переслать magnet/сообщение бота; текст становится
контекстом. Доступ — по `telegram.allowed_user_ids` (пусто = запрет
всем, fail-closed). Бот же шлёт **пинги** о входе в review/готовности.
- **CLI** — `jellybit add <magnet> --context "..."` для отладки.
Источник (magnet / `.torrent` / URL) **отдаём в qBittorrent** — он сам
скачивает; jellybit не делает исходящих запросов на пользовательский URL
(SSRF исключён).
## Хранилище
SQLite, минимум таблиц:
SQLite. Схема покрывает приём, цикл ревью и откат:
- `download` — источник, контекст, hash торрента, категория, состояние,
тайминги.
- `recognition` — тип, название, год, сезон, provider-id, оценка
уверенности, сырой ответ LLM.
- `file_link` — соответствие исходный файл → целевой путь, вид
(видео/субтитры), статус.
- `download` `id`, тип и значение источника, контекст, `infohash`,
`idempotency_key`, состояние, `error_code`/`error_msg`, тайминги.
(infohash может появиться позже приёма — для magnet без метаданных.)
- `recognition` — попытки распознавания: `download_id`, `attempt_no`,
`is_current`, тип, название, год, `provider` (`tmdb|tvdb|none`),
`provider_id`, `confidence`, причины-не-авто, сырой ответ LLM.
- `hint` — накопленные подсказки человека (`download_id`, текст, время).
- `override` — запиненные ручные правки полей (перераспознавание не
затирает).
- `metadata_candidate` — кандидаты базы для выбора (`recognition_id`,
provider, id, название, год, выбран ли).
- `file_link``download_id`, `apply_batch_id`, исходный → целевой путь,
вид (видео/субтитры/…), статус, время. Батч нужен для точечного undo.
Дубликат `infohash` при приёме — повторно не добавляем, ведём к
существующей загрузке (идемпотентность).
## Конфигурация
TOML. В репозитории — `config.example.toml` с placeholder'ами; реальный
`config.toml` рендерится при деплое Ansible-шаблоном из переменных umbar
(секреты — в `vars/secrets.yml` под ansible-vault) и не коммитится.
Пример:
(секреты — `vars/secrets.yml` под ansible-vault), на диске **0600**,
владелец `1000:1000`, не коммитится. Пример:
```toml
[qbittorrent]
url = "http://127.0.0.1:8989"
url = "http://127.0.0.1:8989" # работает при network_mode: host
username = "admin"
password = ""
category = "jellybit"
# qBit отдаёт пути своего контейнера ("/downloads/…"); транслируем в хост-путь
path_map = { "/downloads" = "/srv/downloads" }
[paths]
# хост-пути (видны внутри контейнера jellybit через mount /srv)
downloads = "/srv/downloads"
movies = "/srv/media/movies"
series = "/srv/media/series"
[llm]
# type — дискриминатор реализации; пока поддерживается "openai-compat"
type = "openai-compat"
base_url = "http://127.0.0.1:1234/v1"
api_key = ""
model = "qwen2.5-32b-instruct"
type = "openai-compat"
base_url = "http://127.0.0.1:1234/v1"
api_key = ""
model = "qwen2.5-32b-instruct"
timeout = "120s"
max_retries = 3 # непарсящийся ответ после ретраев → review
[metadata.tmdb]
enabled = true
enabled = false # включается ключом; без матча авто не делаем
api_key = ""
timeout = "10s"
[metadata.tvdb]
enabled = false
api_key = ""
timeout = "10s"
[worker]
poll_interval = "5s" # как часто опрашивать qBittorrent
stuck_after = "1h" # не качается дольше → stuck
magnet_timeout = "30m" # magnet без метаданных дольше → failed
[recognition]
auto_confidence_threshold = 0.85
@@ -121,10 +169,11 @@ auto_confidence_threshold = 0.85
[telegram]
enabled = false
token = ""
allowed_user_ids = []
allowed_user_ids = [] # пусто = запрет всем (fail-closed)
[http]
listen = ":8080"
listen = ":8080"
trusted_subnets = [] # опц. allowlist подсетей; пусто = без ограничений
[log]
level = "info"
@@ -133,36 +182,91 @@ format = "json"
## Логирование
Структурированный JSON через `log/slog`. Каждая загрузка проходит со
сквозным идентификатором; решения распознавания (почему авто/ревью)
логируются явно.
Структурированный JSON через `log/slog`, в stdout (docker подбирает).
Каждая загрузка проходит со сквозным идентификатором; решения
распознавания (почему авто/ревью) и операции с файлами логируются явно.
## Завершение в qBittorrent
`worker` опрашивает qBittorrent по категории и сопоставляет состояния:
- **готово к раскладке:** `uploading`/`stalledUP`/`pausedUP`/`queuedUP`/
`forcedUP` — и **только** когда нет `moving`/`checkingUP`.
- **ещё качается:** `downloading`/`stalledDL`/`metaDL`/`queuedDL`/
`checkingDL`/`forcedDL`.
- **застряло:** `metaDL` дольше `magnet_timeout`, `stalledDL` дольше
`stuck_after``stuck`/`failed`.
- **ошибка:** `error`/`missingFiles``failed`.
Пути файлов берём из API (`save_path`/`content_path` + относительные
имена), не из константы, и транслируем `path_map`. Предполагаем, что
отдельный «incomplete»-каталог в qBittorrent выключен (иначе путь до
завершения иной).
## Раскладка файлов
`layout` создаёт хардлинки в `paths.movies`/`paths.series` по конвенциям
Jellyfin ([jellyfin-layout.md](jellyfin-layout.md)). Правила:
- **Линкуем только файлы.** Целевые каталоги создаём `mkdir -p` (режим
0755, владелец `1000:1000`); каталог не хардлинкуется.
- **Путь сначала санитизируется:** из `title`/сезона/серии убираем
разделители пути, `..`, управляющие символы; финальный
`filepath.Clean`-путь обязан быть строго под библиотекой, иначе отказ
(защита от traversal).
- **Никогда не перезаписываем.** Цель существует и это тот же inode →
готово (идемпотентно); существует и это другой файл → коллизия → review.
- **Батч фиксируется в БД:** статус по каждому файлу; повтор после сбоя
доводит начатое (идемпотентно) либо откатывается.
- **Undo** удаляет только ссылки своего `apply_batch_id` и только если
путь под `paths.movies`/`series` — источник недосягаем.
- **Одна ФС обязательна.** `link(2)` через границы ФС даёт `EXDEV`
падаем с понятной ошибкой; по построению этого не должно случаться.
### Пути и контейнеры (три namespace)
`/srv/downloads` и `/srv/media` — одна ФС на хосте (подтверждено), но
путь существует в трёх пространствах имён:
- **контейнер qBittorrent** видит только `/downloads` (= хост
`/srv/downloads`); `/srv/media` он не монтирует — и не должен.
- **контейнер jellybit** монтирует **общего родителя `/srv`** одним
bind-mount'ом — так `downloads` и `media` гарантированно на одной ФС
внутри него, и хардлинк проходит.
- **хост** — где обе ветки реально на одном томе.
Поэтому путь из qBittorrent (`/downloads/…`) транслируется в хост-путь
(`/srv/downloads/…`) по `qbittorrent.path_map`, и уже он используется как
источник хардлинка.
## Деплой
Jellybit работает в **docker** — в одной среде с qBittorrent и Jellyfin
(они тоже в контейнерах на umbar). Единая среда запуска перевешивает
простоту нативного systemd.
(см. [ADR-2026-06-13-docker-deploy](../adr/ADR-2026-06-13-docker-deploy.md)).
Сборка: статический бинарь (`GOOS=linux GOARCH=amd64 CGO_ENABLED=0`,
сервер на Intel N150) собирается здесь; на сервер во временную build-папку
кладутся бинарь + `Dockerfile` (копирует бинарь в `distroless/static`),
образ собирается на месте и запускается. Go-тулчейн на сервере не нужен.
Сборка — дёшево и сердито: статический бинарь собирается здесь; на сервер
во временную build-папку кладутся бинарь + `Dockerfile` (он просто
копирует бинарь в минимальный образ), образ собирается прямо на сервере и
запускается. Go-тулчейн на сервере не нужен — только docker.
Параметры запуска (в umbar-compose):
- **`network_mode: host`** — чтобы `127.0.0.1:8989` достучался до
qBittorrent (он на bridge с проброшенным портом; из отдельного
bridge-контейнера `127.0.0.1` — это он сам). Заодно `:8080` слушает на
хосте. Прецедент в umbar — glances.
- **`user: "1000:1000"`**, UMASK 022 — единый системный пользователь
umbar; созданные каталоги 0755, файлы-ссылки наследуют inode источника.
- **mount `/srv`** (общий родитель) — для хардлинков (см. выше).
- **mount данных** `/srv/applications/jellybit/data``/data`: SQLite
(`/data/jellybit.db`) и `config.toml`. Без него редеплой стёр бы всё
in-flight состояние.
- **healthcheck** на `/healthz`.
Разделение ответственности:
- **jellybit** (этот репозиторий) — производит статический бинарь и
`Dockerfile`.
- **umbar** — оркестрация деплоя: доставка артефактов, `docker build` и
запуск через docker compose (`playbook-jellybit.yml`).
## Раскладка файлов
Хардлинки в `paths.movies` / `paths.series` по конвенциям Jellyfin.
Детали и крайние случаи — в [jellyfin-layout.md](jellyfin-layout.md).
`/srv/downloads` и `/srv/media` — одна ФС (подтверждено), поэтому
хардлинки применимы. Так как jellybit в docker (см. «Деплой»), контейнеру
монтируем общего родителя `/srv` — чтобы внутри оба каталога остались на
одной ФС и хардлинк проходил.
- **jellybit** (этот репозиторий) — статический бинарь и `Dockerfile`.
- **umbar** — оркестрация: доставка артефактов, `docker build`, запуск
через docker compose (`playbook-jellybit.yml`) с параметрами выше.
## Предполагаемая структура репозитория
@@ -174,21 +278,25 @@ internal/
migrations/ миграции SQLite
web/templates/ шаблоны веб-UI
docs/ specs / adr / drafts
config.example.toml
Dockerfile .dockerignore config.example.toml
```
## Решённые вопросы
- `/srv/downloads` и `/srv/media` — одна ФС (подтверждено); хардлинки
применимы.
- Детект завершения — поллинг qBittorrent раз в несколько секунд
(`worker.poll_interval`). Webhook — возможная оптимизация на будущее
([drafts/ideas.md](../drafts/ideas.md)).
- Секреты — в переменных umbar; `config.toml` рендерится Ansible-шаблоном
при деплое.
- Форма запуска — **docker**, образ собирается на сервере из готового
бинаря (см. «Деплой»).
- Пути/контейнеры — три namespace сведены: qBit отдаёт `/downloads`,
транслируем в хост через `path_map`; jellybit монтирует `/srv`.
- Сеть jellybit↔qBittorrent — `network_mode: host`.
- Состояние — на persistent-томе `/srv/applications/jellybit/data`.
- Детект завершения — поллинг; webhook — на будущее (drafts/ideas).
- Источник (magnet/URL/.torrent) отдаём в qBittorrent — без SSRF.
- Авто-раскладка требует подтверждённого матча в базе; иначе review.
- Веб-UI в v1 без авторизации (доверенная LAN, опц. allowlist подсетей).
- Форма запуска — docker, образ собирается на сервере; контейнер под
`1000:1000`, `network_mode: host`, mount `/srv` + data-том.
## Открытые вопросы
Существенных пока нет.
- Конфигурация Jellyfin (URL + API-ключ) и триггер скана — когда Jellyfin
будет развёрнут в umbar (сейчас его там нет).
- Подтвердить, что «incomplete»-каталог qBittorrent выключен (иначе путь
файлов до завершения иной).