# Архитектура ## Назначение Jellybit принимает торрент с текстовым контекстом, скачивает его через qBittorrent, определяет содержимое (фильм или сериал с сезонами и сериями) и раскладывает файлы по конвенциям Jellyfin — хардлинками, не трогая исходную раздачу. ## Принципы - **Один статический бинарь.** Доставка — копированием на сервер. См. [ADR-2026-06-13-go-single-binary](../adr/ADR-2026-06-13-go-single-binary.md). - **Источник неприкосновенен** (жёсткий инвариант). jellybit делает только `mkdir`, `link(2)` и `unlink` *своих* целевых ссылок (для undo). Никогда не `unlink`/`rename` под `paths.downloads`. См. [ADR-2026-06-13-hardlinks](../adr/ADR-2026-06-13-hardlinks.md). - **Выход распознавания недоверенный.** Имена файлов, контекст и сообщение бота управляются извне. Целевой путь всегда санитизируется и проверяется, что он строго под `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: приём + парсер сообщений бота + исходящие пинги | | `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 / completed** — `worker` поллит qBittorrent по категории (`worker.poll_interval`, 5 с). Готовность — только когда файлы на месте (не `moving`/`checking*`), см. «Завершение в qBittorrent». - **recognizing** — `recognize` строит план и оценку уверенности ([recognition.md](recognition.md)). Невалидный/непарсящийся ответ LLM → review (не failed). - **review** — план уходит человеку ([review-ux.md](review-ux.md)); цикл `review ⇄ recognizing` — перераспознавание по подсказке. - **linking** — `layout` создаёт хардлинки; идемпотентно, батчем. - **done** — опционально дёргаем скан Jellyfin; доступен **undo** → `reverted` (убрать созданные ссылки). - **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](../drafts/ideas.md). - **Telegram-бот** — переслать magnet/сообщение бота; текст становится контекстом. Доступ — по `telegram.allowed_user_ids` (пусто = запрет всем, fail-closed). Бот же шлёт **пинги** о входе в review/готовности. - **CLI** — `jellybit add --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`, не коммитится. Пример: ```toml [qbittorrent] url = "http://qbit:8989" # по имени сервиса в общей docker-сети username = "admin" password = "" category = "jellybit" savepath = "/srv/media/downloads" # куда qBit кладёт загрузки (задаём при добавлении) # Обычно пусто: все медиа-приложения монтируют /srv/media:/srv/media идентично, # поэтому путь из API уже = хост-путь. path_map — фолбэк, если пути разойдутся. 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" [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_after` → `stuck`/`failed`. - **ошибка:** `error`/`missingFiles` → `failed`. Пути файлов берём из API (`save_path`/`content_path` + относительные имена), не из константы (обычно это уже хост-путь). «Incomplete»-каталог в qBittorrent **включён** (`/srv/media/incomplete`): пока качается — файлы там, по завершении qBit переносит их в `/srv/media/downloads` (состояние `moving` — дожидаемся окончания переноса и только потом берём финальный путь). ## Раскладка файлов `layout` создаёт хардлинки в `paths.movies`/`paths.series` по конвенциям Jellyfin ([jellyfin-layout.md](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`. - **Jellyfin** — библиотеки указывают на `movies`/`series` (не на корень `/srv/media`, иначе в индекс попадут downloads/incomplete). ## Деплой Jellybit работает в **docker** — в одной среде с qBittorrent и Jellyfin (см. [ADR-2026-06-13-docker-deploy](../adr/ADR-2026-06-13-docker-deploy.md)). Сборка: статический бинарь (`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). - Источник (magnet/URL/.torrent) отдаём в qBittorrent — без SSRF. - Авто-раскладка требует подтверждённого матча в базе; иначе review. - Веб-UI в v1 без авторизации (доверенная LAN, опц. allowlist подсетей). - Форма запуска — docker, образ собирается на сервере; контейнер под `1000:1000`, в общей docker-сети, mount `/srv/media` + data-том. ## Открытые вопросы - Конфигурация Jellyfin (URL + API-ключ) и триггер скана — когда Jellyfin будет развёрнут в umbar (сейчас его там нет).