Правки после ревью документации
This commit is contained in:
+195
-87
@@ -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 выключен (иначе путь
|
||||
файлов до завершения иной).
|
||||
|
||||
Reference in New Issue
Block a user