# Архитектура ## Назначение 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, POST-формы с redirect) | | `tgbot` | Telegram: приём + парсер сообщений бота + исходящие пинги | | `jellyfin` | триггер пересканирования медиатеки после раскладки (опц.) | | `config` | загрузка TOML-конфига | ## Поток и машина состояний Жизненный цикл загрузки (ingest → downloading → … → done/reverted), полный граф состояний с переходами и сопоставление состояний qBittorrent — в отдельной спецификации [workflow.md](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](../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|tvmaze|none`), `provider_id`, `confidence`, причины-не-авто, сырой ответ LLM и структурированный `plan` (каноничный JSON `recognize.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.example.toml) (источник истины, не дублируем его здесь). Реальный `config.toml` рендерится при деплое Ansible-шаблоном из переменных umbar (секреты — `vars/secrets.yml` под ansible-vault), на диске **0600**, владелец `1000:1000`, не коммитится. Структура секций: `[qbittorrent]` (доступ + категория/тег для push/pull), `[paths]` (хост-пути песочницы), `[storage]` (путь к SQLite), `[llm]` (провайдер распознавания, см. [recognition.md](recognition.md)), `[metadata.tmdb|tvdb|tvmaze]` (опц. базы), `[jellyfin]` (опц. пересканирование), `[worker]` (интервал поллинга и таймауты, см. [workflow.md](workflow.md)), `[recognition]` (порог уверенности), `[telegram]`, `[http]`, `[log]`. ## Логирование Структурированный JSON через `log/slog`, в stdout (docker подбирает). Каждая загрузка проходит со сквозным идентификатором; решения распознавания (почему авто/ревью) и операции с файлами логируются явно. ## Раскладка файлов `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` — источник недосягаем. - **Хардлинк предпочтителен, но есть фолбэк.** По построению источник и цель — на одной ФС (единая песочница `/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](../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 (см. «Пути и контейнеры»); каталоги 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-том. ## Открытые вопросы - (пока нет)