20 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, POST-формы с redirect) |
tgbot |
Telegram: приём + парсер сообщений бота + исходящие пинги |
jellyfin |
триггер пересканирования медиатеки после раскладки (опц.) |
config |
загрузка TOML-конфига |
Поток и машина состояний
Жизненный цикл загрузки (ingest → downloading → … → done/reverted),
полный граф состояний с переходами и сопоставление состояний qBittorrent —
в отдельной спецификации workflow.md. Ключевое: переходами
владеет worker, он же сериализует команды транспортов под per-download
блокировкой, а состояние персистентно в SQLite.
Транспорты
Все ведут в один 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|tvmaze|none),provider_id,confidence, причины-не-авто, сырой ответ LLM и структурированныйplan(каноничный JSONrecognize.Plan— файл → роль/сезон/серия для превью и применения).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 (источник истины, не
дублируем его здесь). Реальный config.toml рендерится при деплое
Ansible-шаблоном из переменных umbar (секреты — vars/secrets.yml под
ansible-vault), на диске 0600, владелец 1000:1000, не коммитится.
Структура секций: [qbittorrent] (доступ + категория/тег для push/pull),
[paths] (хост-пути песочницы), [storage] (путь к SQLite), [llm]
(провайдер распознавания, см. recognition.md),
[metadata.tmdb|tvdb|tvmaze] (опц. базы), [jellyfin] (опц.
пересканирование), [worker] (интервал поллинга и таймауты, см.
workflow.md), [recognition] (порог уверенности),
[telegram], [http], [log].
Логирование
Структурированный JSON через log/slog, в stdout (docker подбирает).
Каждая загрузка проходит со сквозным идентификатором; решения
распознавания (почему авто/ревью) и операции с файлами логируются явно.
Раскладка файлов
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— источник недосягаем. - Хардлинк предпочтителен, но есть фолбэк. По построению источник и
цель — на одной ФС (единая песочница
/srv/media), иlink(2)проходит. Если ФС всё же не поддерживает жёсткие ссылки или они между разными ФС (EXDEV/ENOTSUP/EOPNOTSUPP/EPERM),layoutне падает, а копирует файл (через временный файл + атомарныйrename) и пишет в логWarn(статус ссылки —copied): задача доходит до конца ценой дублирования места. Источник при этом всё равно не трогаем.
Пути и контейнеры — единая песочница /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-том.
Открытые вопросы
- (пока нет)