Files
jellybit/docs/specs/architecture.md
T

25 KiB
Raw Blame History

Архитектура

Назначение

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 → «Привязать заново» → recognizing (ручная перепривязка, всегда через review)
review   → «Позже» → deferred → review
любой    → «Отклонить» → cancelled
  • ingest — приняли источник + контекст, отдали в qBittorrent (категория jellybit), записали в БД с ключом идемпотентности.
  • downloading / completedworker поллит qBittorrent по категории (worker.poll_interval, 5 с). Готовность — только когда файлы на месте (не moving/checking*), см. «Завершение в qBittorrent».
  • recognizingrecognize строит план и оценку уверенности (recognition.md). Невалидный/непарсящийся ответ LLM → review (не failed).
  • review — план уходит человеку (review-ux.md); цикл review ⇄ recognizing — перераспознавание по подсказке.
  • linkinglayout создаёт хардлинки; идемпотентно, батчем.
  • done — при входе неблокирующе дёргаем пересканирование Jellyfin (опц., см. «Пересканирование Jellyfin»); доступен undoreverted (убрать созданные ссылки).
  • deferred / cancelled / failed / stuck — «Позже», «Отклонить», ошибка (ретраибельна), не качается дольше таймаута.
  • reverted → recognizing — «Привязать заново»: после отката можно перезапустить распознавание для той же раздачи. Перепривязка всегда идёт через review с ручным подтверждением (авто-раскладку не делаем), и требует, чтобы раздача всё ещё была в qBittorrent.

Все переходы и команды идут через 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/готовности.
  • CLIjellybit add <magnet> --context "..." для отладки.

Источник (magnet / .torrent / URL) отдаём в qBittorrent — он сам скачивает; jellybit не делает исходящих запросов на пользовательский URL (SSRF исключён).

Хранилище

SQLite. Схема покрывает приём, цикл ревью и откат:

  • downloadid, тип и значение источника, контекст, 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_linkdownload_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_afterstuck/failed.
  • ошибка: error/missingFilesfailed.

Пути файлов берём из 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) в эту песочницу не попадают.

  • qBitsavepath=/srv/media/downloads, temp /srv/media/incomplete.
  • jellybit — читает downloads, пишет в movies/series; свой SQLite/конфиг — отдельным mount'ом /srv/applications/jellybit/data.
  • 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 (см. «Пути и контейнеры»); /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).
  • Пересканирование Jellyfin после раскладки — POST /Library/Refresh (скан всех библиотек, инкрементальный), неблокирующе на входе в done; опц., включается [jellyfin].
  • Источник (magnet/URL/.torrent) отдаём в qBittorrent — без SSRF.
  • Авто-раскладка требует подтверждённого матча в базе; иначе review.
  • Веб-UI в v1 без авторизации (доверенная LAN, опц. allowlist подсетей).
  • Форма запуска — docker, образ собирается на сервере; контейнер под 1000:1000, в общей docker-сети, mount /srv/media + data-том.

Открытые вопросы

  • (пока нет)