25 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/TVMaze (опц.) |
layout |
конвенции Jellyfin, санитизация путей, хардлинкер, undo |
store |
SQLite: загрузки, распознавание, подсказки, ссылки |
httpapi |
REST + веб-UI (server-rendered, htmx) |
tgbot |
Telegram: приём + парсер сообщений бота + исходящие пинги |
jellyfin |
триггер пересканирования медиатеки после раскладки (опц.) |
config |
загрузка TOML-конфига |
Поток и машина состояний
ingest → downloading → completed → recognizing ──┬─ авто ────────────────→ linking → done
│ │ │ └─ review ⇄ recognizing ─→ linking → done
│ │ └─ moving/checking (ещё не готов)
│ └─ stuck (не качается дольше таймаута)
└─ failed ⇄ retry
done → undo → reverted
reverted/cancelled → «Привязать заново» → recognizing (ручная перепривязка, всегда через review)
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 (опц.,
см. «Пересканирование Jellyfin»); доступен undo →
reverted(убрать созданные ссылки). - deferred / cancelled / failed / stuck — «Позже», «Отклонить», ошибка (ретраибельна), не качается дольше таймаута.
- reverted / cancelled → recognizing — «Привязать заново»: после отката или отклонения можно перезапустить распознавание для той же раздачи. Перепривязка всегда идёт через review с ручным подтверждением (авто-раскладку не делаем), и требует, чтобы раздача всё ещё была в qBittorrent.
- review → recognizing — кроме «Уточнить» (подсказка + перераспознавание) есть «Распознать заново»: повторный прогон распознавания без новой подсказки, по уже накопленному контексту и подсказкам.
Все переходы и команды идут через 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. - 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, не коммитится. Пример:
[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"
[jellyfin]
enabled = false # включить пересканирование медиатеки после раскладки
url = "http://jellyfin:8096" # по имени сервиса в общей docker-сети
api_key = "" # API-ключ Jellyfin (Dashboard → API Keys)
proxy = "" # опц. HTTP-прокси
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). Правила:
- Линкуем только файлы. Целевые каталоги создаём
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, конфиг — отдельным/srv/applications/jellybit/config. - Jellyfin — библиотеки указывают на
movies/series(не на корень/srv/media, иначе в индекс попадут downloads/incomplete).
Пересканирование Jellyfin
После успешной раскладки (вход в done) worker неблокирующе просит Jellyfin
пересканировать медиатеку, чтобы новые файлы быстрее появились в проигрывателе.
Включается конфигом [jellyfin] (по умолчанию выключено); без него скан не
дёргается.
- Один вызов —
POST /Library/Refresh(скан всех библиотек). Скан инкрементальный, поэтому полный дёшев; точечный скан конкретной папки не делаем — сложнее и не в духе сервиса («минимум компонентов»). - Авторизация — API-ключ Jellyfin в заголовке
X-Emby-Token. - Неблокирующе и вне
w.mu(как пинги Telegram): вызов уходит в сеть в отдельной горутине с фоновым контекстом. Недоступность Jellyfin не влияет на состояние задачи — ошибка лишь логируется (Warn). - Адресация — по имени сервиса в общей docker-сети (
http://jellyfin:8096).
Деплой
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):
- Общая 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 (см. «Пути и контейнеры»); каталоги jellybit — отдельно. - mount конфига
/srv/applications/jellybit/config→/config(ro):config.toml(0600). Восстановим при деплое (рендерит плейбук umbar) — бекапить не нужно. - mount данных
/srv/applications/jellybit/data→/data: SQLite (/data/jellybit.db). Бекапить-и-не-терять — без него редеплой стёр бы всё 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).
- Пересканирование Jellyfin после раскладки —
POST /Library/Refresh(скан всех библиотек, инкрементальный), неблокирующе на входе вdone; опц., включается[jellyfin]. - Источник (magnet/URL/.torrent) отдаём в qBittorrent — без SSRF.
- Авто-раскладка требует подтверждённого матча в базе; иначе review.
- Веб-UI в v1 без авторизации (доверенная LAN, опц. allowlist подсетей).
- Форма запуска — docker, образ собирается на сервере; контейнер под
1000:1000, в общей docker-сети, mount/srv/media+ data-том.
Открытые вопросы
- (пока нет)