18 KiB
Архитектура
Назначение
Jellybit принимает торрент с текстовым контекстом, скачивает его через qBittorrent, определяет содержимое (фильм или сериал с сезонами и сериями) и раскладывает файлы по конвенциям Jellyfin — хардлинками, не трогая исходную раздачу.
Принципы
- Один статический бинарь. Доставка — копированием на сервер. См. ADR-2026-06-13-go-single-binary.
- Источник неприкосновенен (жёсткий инвариант). jellybit делает
только
mkdir,link(2)иunlinkсвоих целевых ссылок (для undo). Никогда неunlink/renameподpaths.downloads. См. ADR-2026-06-13-hardlinks. - Выход распознавания недоверенный. Имена файлов, контекст и
сообщение бота управляются извне. Целевой путь всегда санитизируется и
проверяется, что он строго под
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). Невалидный/непарсящийся ответ LLM → review (не failed). - review — план уходит человеку (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. - 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, не коммитится. Пример:
[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). Правила:
- Линкуем только файлы. Целевые каталоги создаём
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).
Сборка: статический бинарь (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 выключен (иначе путь файлов до завершения иной).