Files
jellybit/docs/specs/architecture.md
T

303 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Архитектура
## Назначение
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 выключен (иначе путь
файлов до завершения иной).