Files
jellybit/docs/specs/architecture.md
T

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

Конфигурация

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_afterstuck/failed.
  • ошибка: error/missingFilesfailed.

Пути файлов берём из 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 выключен (иначе путь файлов до завершения иной).