345 lines
22 KiB
Markdown
345 lines
22 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/TVMaze (опц.) |
|
||
| `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). В v1 **без авторизации** (доверенная LAN). Поле
|
||
`http.trusted_subnets` зарезервировано, но **пока не применяется**:
|
||
деплой только в локальную сеть без доступа из интернета, поэтому
|
||
allowlist-middleware и авторизацию отложили — [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** (v1 SHA-1 / v2 SHA-256): берём из
|
||
magnet (`xt=urn:btih:`) или считаем из `.torrent`; этим же оперирует сам
|
||
qBittorrent. Идемпотентность — **только для активных задач**: повторное
|
||
добавление, пока задача в работе, присоединяется к ней. Если прежняя
|
||
задача для этого infohash уже терминальна (`done`/`cancelled`/`failed`/
|
||
`reverted`), новое добавление заводит **новую** задачу — перекачать тот же
|
||
торрент спустя месяцы можно без проблем (покажем, что infohash уже
|
||
обрабатывался, и прежний результат). Разные раздачи одного фильма (репаки)
|
||
имеют разные infohash → разные задачи.
|
||
|
||
## Конфигурация
|
||
|
||
TOML. В репозитории — `config.example.toml` с placeholder'ами; реальный
|
||
`config.toml` рендерится при деплое Ansible-шаблоном из переменных umbar
|
||
(секреты — `vars/secrets.yml` под ansible-vault), на диске **0600**,
|
||
владелец `1000:1000`, не коммитится. Пример:
|
||
|
||
```toml
|
||
[qbittorrent]
|
||
url = "http://qbit:8989" # по имени сервиса в общей docker-сети
|
||
username = "admin"
|
||
password = ""
|
||
category = "jellybit"
|
||
savepath = "/srv/media/downloads" # куда qBit кладёт загрузки (задаём при добавлении)
|
||
# Обычно пусто: все медиа-приложения монтируют /srv/media:/srv/media идентично,
|
||
# поэтому путь из API уже = хост-путь. path_map — фолбэк, если пути разойдутся:
|
||
# самый длинный совпавший префикс save_path (ключ) меняется на хост-префикс
|
||
# (значение), совпадение — по границам сегментов. Напр. {"/data" = "/srv/media"}.
|
||
path_map = {}
|
||
|
||
[paths]
|
||
# хост-пути (видны внутри контейнера через mount /srv/media)
|
||
downloads = "/srv/media/downloads"
|
||
movies = "/srv/media/movies"
|
||
series = "/srv/media/series"
|
||
|
||
[llm]
|
||
# type — дискриминатор реализации; пока поддерживается "openai-compat"
|
||
type = "openai-compat"
|
||
# LLM на хосте (LM Studio) из bridged-контейнера — через host.docker.internal,
|
||
# не 127.0.0.1; либо вынести LLM в контейнер общей сети.
|
||
base_url = "http://host.docker.internal:1234/v1"
|
||
api_key = ""
|
||
model = "qwen2.5-32b-instruct"
|
||
proxy = "" # опц. HTTP-прокси (для удалённых эндпоинтов)
|
||
timeout = "120s"
|
||
max_retries = 3 # непарсящийся ответ после ретраев → review
|
||
|
||
[metadata.tmdb]
|
||
enabled = false # включается ключом; без матча авто не делаем
|
||
api_key = ""
|
||
proxy = "" # опц. HTTP-прокси для доступа к базе
|
||
timeout = "10s"
|
||
|
||
[metadata.tvdb]
|
||
enabled = false
|
||
api_key = ""
|
||
proxy = ""
|
||
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 = [] # зарезервировано, ПОКА НЕ ПРИМЕНЯЕТСЯ (деплой только в LAN)
|
||
|
||
[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` + относительные имена из
|
||
`/torrents/files`, уже включающие корневую папку торрента), не из
|
||
константы (обычно это уже хост-путь). «Incomplete»-каталог в
|
||
qBittorrent **включён** (`/srv/media/incomplete`): пока качается — файлы
|
||
там, по завершении qBit переносит их в `/srv/media/downloads` (состояние
|
||
`moving` — дожидаемся окончания переноса и только потом берём финальный
|
||
путь).
|
||
|
||
## Раскладка файлов
|
||
|
||
`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` —
|
||
падаем с понятной ошибкой; по построению этого не должно случаться.
|
||
|
||
### Пути и контейнеры — единая песочница `/srv/media`
|
||
|
||
Весь медиа-стек лежит под одним каталогом и монтируется **идентично**
|
||
(`/srv/media:/srv/media`) во все медиа-приложения:
|
||
|
||
```
|
||
/srv/media/
|
||
incomplete/ ← qBit качает сюда
|
||
downloads/ ← готовые раздачи (источник хардлинка)
|
||
movies/ series/ ← библиотека Jellyfin (цель хардлинка)
|
||
```
|
||
|
||
Так как всё под одним mount'ом, и **хардлинк** (downloads → movies/series),
|
||
и **мгновенный move** qBit (incomplete → downloads) работают — нет границ
|
||
между точками монтирования (`EXDEV`). Путь из qBittorrent
|
||
(`save_path`/`content_path`) уже равен хост-пути, трансляция не нужна
|
||
(`path_map` — фолбэк, обычно пуст). Секреты и чужие приложения
|
||
(`/srv/applications`) в эту песочницу не попадают.
|
||
|
||
- **qBit** — `savepath=/srv/media/downloads`, temp `/srv/media/incomplete`.
|
||
- **jellybit** — читает `downloads`, пишет в `movies`/`series`; свой
|
||
SQLite/конфиг — отдельным mount'ом `/srv/applications/jellybit/data`.
|
||
- **Jellyfin** — библиотеки указывают на `movies`/`series` (не на корень
|
||
`/srv/media`, иначе в индекс попадут downloads/incomplete).
|
||
|
||
## Деплой
|
||
|
||
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):
|
||
|
||
- **Общая docker-сеть** (external, напр. `media-net`) — jellybit, qBit и
|
||
(позже) Jellyfin в ней; адресуемся по именам (`http://qbit:8989`,
|
||
`http://jellyfin:8096`). Веб-UI jellybit публикуем на хост (`8080:8080`)
|
||
для LAN. Учесть: qBit валидирует Host-заголовок — выставить
|
||
`WebUI\ServerDomains=*` (umbar); LLM на хосте достаётся через
|
||
`host.docker.internal` (`extra_hosts: host-gateway`).
|
||
- **`user: "1000:1000"`**, UMASK 022 — единый системный пользователь
|
||
umbar; созданные каталоги 0755, файлы-ссылки наследуют inode источника.
|
||
- **mount `/srv/media`** (единая песочница) — для хардлинков и move
|
||
(см. «Пути и контейнеры»); `/srv/applications/jellybit/data` — отдельно.
|
||
- **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
|
||
```
|
||
|
||
## Решённые вопросы
|
||
|
||
- Пути/контейнеры — единая песочница `/srv/media:/srv/media` (подпапки
|
||
incomplete/downloads/movies/series) монтируется идентично во все
|
||
медиа-приложения; путь из API = хост-путь; хардлинк и move в пределах
|
||
одного mount'а. `/srv/applications` в песочницу не попадает.
|
||
- Сеть — общая docker-сеть, адресация по именам (`qbit:8989`); host-режим
|
||
не используем. qBit: `WebUI\ServerDomains=*`; LLM на хосте — через
|
||
`host.docker.internal`.
|
||
- qBit: «incomplete» включён (`/srv/media/incomplete`), завершение
|
||
проходит через `moving`; jellybit авторизуется логином/паролем
|
||
(docker-подсеть не входит в LAN-whitelist qBit).
|
||
- Внешние базы — HTTP-прокси на клиента (`proxy` в `[metadata.*]`/`[llm]`).
|
||
- Идентификатор торрента — infohash; идемпотентность только для активных
|
||
задач (повторная закачка спустя время → новая задача).
|
||
- Состояние — на persistent-томе `/srv/applications/jellybit/data`.
|
||
- Детект завершения — поллинг; webhook — на будущее (drafts/ideas).
|
||
- Источник (magnet/URL/.torrent) отдаём в qBittorrent — без SSRF.
|
||
- Авто-раскладка требует подтверждённого матча в базе; иначе review.
|
||
- Веб-UI в v1 без авторизации (доверенная LAN, опц. allowlist подсетей).
|
||
- Форма запуска — docker, образ собирается на сервере; контейнер под
|
||
`1000:1000`, в общей docker-сети, mount `/srv/media` + data-том.
|
||
|
||
## Открытые вопросы
|
||
|
||
- Конфигурация Jellyfin (URL + API-ключ) и триггер скана — когда Jellyfin
|
||
будет развёрнут в umbar (сейчас его там нет).
|