Files

20 KiB
Raw Permalink 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, 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/готовности.
  • 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|tvmaze|none), provider_id, confidence, причины-не-авто, сырой ответ LLM и структурированный plan (каноничный JSON recognize.Plan — файл → роль/сезон/серия для превью и применения).
  • 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 (источник истины, не дублируем его здесь). Реальный 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) в эту песочницу не попадают.

  • qBitsavepath=/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-том.

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

  • (пока нет)