303 lines
18 KiB
Markdown
303 lines
18 KiB
Markdown
# Архитектура
|
||
|
||
## Назначение
|
||
|
||
Jellybit принимает торрент с текстовым контекстом, скачивает его через
|
||
qBittorrent, определяет содержимое (фильм или сериал с сезонами и
|
||
сериями) и раскладывает файлы по конвенциям Jellyfin — хардлинками, не
|
||
трогая исходную раздачу.
|
||
|
||
## Принципы
|
||
|
||
- **Один статический бинарь.** Доставка — копированием на сервер. См.
|
||
[ADR-2026-06-13-go-single-binary](../adr/ADR-2026-06-13-go-single-binary.md).
|
||
- **Источник неприкосновенен** (жёсткий инвариант). 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, но
|
||
авто-раскладка без матча в базе не делается (см. recognition.md).
|
||
- **Минимум компонентов.** В духе umbar — без лишних сервисов.
|
||
|
||
## Компоненты
|
||
|
||
| Пакет | Ответственность |
|
||
| ----------- | -------------------------------------------------------- |
|
||
| `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 ⇄ recognizing ─→ linking → done
|
||
│ │ └─ moving/checking (ещё не готов)
|
||
│ └─ stuck (не качается дольше таймаута)
|
||
└─ failed ⇄ retry
|
||
|
||
done → undo → reverted
|
||
review → «Позже» → deferred → review
|
||
любой → «Отклонить» → cancelled
|
||
```
|
||
|
||
- **ingest** — приняли источник + контекст, отдали в qBittorrent
|
||
(категория `jellybit`), записали в БД с ключом идемпотентности.
|
||
- **downloading / completed** — `worker` поллит qBittorrent по категории
|
||
(`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** — «Позже», «Отклонить»,
|
||
ошибка (ретраибельна), не качается дольше таймаута.
|
||
|
||
Все переходы и команды идут через `worker` под per-download блокировкой —
|
||
два транспорта не гонятся за одно состояние. Состояние персистентно в
|
||
SQLite; на старте `worker` сверяет категорию qBittorrent с БД и
|
||
продолжает.
|
||
|
||
## Транспорты
|
||
|
||
Все ведут в один `Ingest(req)`; действия пользователя (apply / refine /
|
||
reject / defer / undo) — команды к `worker`:
|
||
|
||
- **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. Схема покрывает приём, цикл ревью и откат:
|
||
|
||
- `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), на диске **0600**,
|
||
владелец `1000:1000`, не коммитится. Пример:
|
||
|
||
```toml
|
||
[qbittorrent]
|
||
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"
|
||
timeout = "120s"
|
||
max_retries = 3 # непарсящийся ответ после ретраев → review
|
||
|
||
[metadata.tmdb]
|
||
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
|
||
|
||
[telegram]
|
||
enabled = false
|
||
token = ""
|
||
allowed_user_ids = [] # пусто = запрет всем (fail-closed)
|
||
|
||
[http]
|
||
listen = ":8080"
|
||
trusted_subnets = [] # опц. allowlist подсетей; пусто = без ограничений
|
||
|
||
[log]
|
||
level = "info"
|
||
format = "json"
|
||
```
|
||
|
||
## Логирование
|
||
|
||
Структурированный 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
|
||
(см. [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-тулчейн на сервере не нужен.
|
||
|
||
Параметры запуска (в 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`) с параметрами выше.
|
||
|
||
## Предполагаемая структура репозитория
|
||
|
||
```
|
||
cmd/jellybit/ точка входа, сборка зависимостей
|
||
internal/
|
||
ingest/ qbt/ worker/ recognize/ llm/ metadata/
|
||
layout/ store/ httpapi/ tgbot/ config/
|
||
migrations/ миграции SQLite
|
||
web/templates/ шаблоны веб-UI
|
||
docs/ specs / adr / drafts
|
||
Dockerfile .dockerignore config.example.toml
|
||
```
|
||
|
||
## Решённые вопросы
|
||
|
||
- Пути/контейнеры — три 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 выключен (иначе путь
|
||
файлов до завершения иной).
|