# Архитектура Обзор: как сложено и где что работает. **Поведение системы здесь не описывается** — его нормативный дом `openspec/specs/`; ниже компоненты только ссылаются на свои capability. Инварианты и их severity — в [CLAUDE.md](../CLAUDE.md), схема хранилища — в [database.md](database.md), периметр — в [security.md](security.md). ## Принципы - **Один статический бинарь.** Доставка — образом с готовым бинарём внутри. См. [ADR-2026-06-13-go-single-binary](adr/ADR-2026-06-13-go-single-binary.md). - **Источник неприкосновенен.** Только `mkdir`, `link(2)` и `unlink` *своих* целевых ссылок. См. [ADR-2026-06-13-hardlinks](adr/ADR-2026-06-13-hardlinks.md). - **Выход распознавания недоверенный.** Безопасность держится на валидации целевого пути, а не на промпте — [security.md](security.md). - **Единое ядро, тонкие транспорты.** Логика приёма — в use-case `ingest`; переходами состояний владеет `worker`. HTTP API, веб-UI, Telegram и CLI лишь складывают команды, `worker` их сериализует. - **Опциональные внешние зависимости.** Метабазы (TMDB/TVDB/TVMaze) и триггер Jellyfin включаются конфигом; без них сервис работает, но авто-раскладка без подтверждённого матча не делается — [ADR-2026-06-13-auto-link-requires-db-match](adr/ADR-2026-06-13-auto-link-requires-db-match.md). - **Минимум компонентов.** В духе umbar — без зоопарка сервисов. ## Компоненты `cmd/jellybit` — точка входа и сборка зависимостей; всё остальное — `internal/*`. | Пакет | Ответственность | Capability | | --- | --- | --- | | `ingest` | use-case приёма загрузки, общий для всех транспортов | [ingest](../openspec/specs/ingest/spec.md) | | `magnet`, `torrent` | разбор magnet-ссылки и байтов `.torrent`, извлечение инфохэшей | [ingest](../openspec/specs/ingest/spec.md) | | `worker` | владелец машины состояний: поллинг qBittorrent, сериализация команд, фоновая сверка | [download-tracking](../openspec/specs/download-tracking/spec.md), [state-reconciliation](../openspec/specs/state-reconciliation/spec.md), [live-status](../openspec/specs/live-status/spec.md) | | `qbt` | клиент qBittorrent WebUI API (сессия, добавление, опрос, удаление) | [download-tracking](../openspec/specs/download-tracking/spec.md) | | `recognize` | пред-парс имени, вызов LLM, разбор плана, модель уверенности | [recognition](../openspec/specs/recognition/spec.md) | | `llm` | провайдер LLM за интерфейсом (дискриминатор `[llm].type`) | [recognition](../openspec/specs/recognition/spec.md) | | `metadata` | интерфейс метабаз + TMDB/TVDB/TVMaze (опц.) | [metadata-match](../openspec/specs/metadata-match/spec.md) | | `naming` | единая логика целевых имён и отображаемого имени раздачи | [file-layout](../openspec/specs/file-layout/spec.md), [ingest](../openspec/specs/ingest/spec.md) | | `layout` | санитизация путей, хардлинкер, copy-fallback, undo, владение путём | [file-layout](../openspec/specs/file-layout/spec.md), [state-reconciliation](../openspec/specs/state-reconciliation/spec.md) | | `store` | SQLite: загрузки, распознавания, подсказки, кандидаты, ссылки | [identity](../openspec/specs/identity/spec.md) | | `ident` | генерация и нормализация ULID | [identity](../openspec/specs/identity/spec.md) | | `httpapi` | REST + веб-UI на htmx (server-rendered партиалы) | [web-ui](../openspec/specs/web-ui/spec.md), [review](../openspec/specs/review/spec.md) | | `tgbot` | Telegram: приём, парсер сообщений торрент-бота, карточки, пинги | [notifications](../openspec/specs/notifications/spec.md), [review](../openspec/specs/review/spec.md) | | `jellyfin` | триггер пересканирования медиатеки (опц.) | [file-layout](../openspec/specs/file-layout/spec.md) | | `config` | загрузка и валидация TOML на старте | — | | `logging`, `logctx` | slog-настройка и протяжка корреляции через контекст | [identity](../openspec/specs/identity/spec.md) | | `archrules` | собственный анализатор архитектурных правил (часть гейта) | — | Транспорты приёма — HTTP API, веб-UI, Telegram и CLI (`jellybit add`) — ведут в один `ingest`; действия пользователя (apply / refine / reject / defer / undo / retry / delete / dismiss) идут командами к `worker`. ## Внешние границы и форматы - **qBittorrent WebUI API** — единственный способ качать: источник (magnet, URL, `.torrent`) **отдаём ему**, сами по пользовательскому URL не ходим (SSRF исключён). Пути берём из API (`save_path` + относительные имена из `/torrents/files`), не из константы. - **LLM** — OpenAI-совместимый Chat Completions (`[llm].type = "openai-compat"`), структурированный вывод через `response_format: json_object`; валидация ответа своя, в Go. - **Метабазы** — TMDB, TVDB, TVMaze (последняя без ключа, только сериалы). - **Jellyfin** — один вызов `POST /Library/Refresh`, авторизация заголовком `X-Emby-Token`. - **Telegram Bot API** — приём сообщений и исходящие карточки/пинги. - **Сообщение торрент-бота** — чужой текстовый формат, разбирается парсером `tgbot`; наблюдения по формату — в [research/torrent-bot-message.md](research/torrent-bot-message.md). ## Эксплуатация - **Где работает, что рядом, кто перезапускает:** домашний медиа-сервер umbar (Intel N150), docker в общей сети с qBittorrent и Jellyfin. Перезапускает docker по `restart`-политике и плейбук umbar при редеплое; человек — руками, когда всё плохо. Оператор один и он же владелец. - **Внешние зависимости поимённо и чем каждая отказывает:** | Зависимость | Обязательна | Как отказывает | | --- | --- | --- | | qBittorrent | да | недоступен (весь цикл встаёт, тик поллинга краснеет); отдаёт раздачу без файлов; теряет раздачу (пропажа источника); переходные состояния `moving`/`checking*` выглядят как готовность | | LLM-эндпоинт | да | недоступен; отвечает медленно (минуты); отдаёт не-JSON или JSON не по схеме; отдаёт правдоподобную выдумку — самый неприятный случай, потому что молчаливый | | TMDB/TVDB/TVMaze | нет | недоступны; лимит запросов; пустой результат (норма для русского контента); несколько равнозначных кандидатов | | Jellyfin | нет | недоступен — скан просто не случится, состояние задачи не страдает | | Telegram Bot API | нет | недоступен — уведомление теряется, состояние задачи не страдает | | Диск `/srv/media` | да | переполнен (особенно на copy-fallback); ФС без хардлинков; файл исчез между проверкой и `link(2)` | | SQLite | да | `database is locked` при конкурентной записи; файл тома не смонтирован | - **Кто заметит отказ и когда:** владелец — по отсутствию ожидаемого пинга и по задаче, застрявшей в промежуточном состоянии; логи в stdout контейнера. Автоматического алертинга нет, метрик нет — только уведомления в Telegram о падении загрузки и о рассинхроне. - **Характер потока:** непрерывный фон (тик поллинга qBittorrent, по умолчанию 5 с, и периодическая сверка) плюс редкие события по запросу человека. Объём — единицы загрузок в день, десятки одновременно; ориентир масштаба и его аудит — задача в беклоге. ## Единые точки проекта Материал для вопроса «не появился ли второй способ делать то, что уже делается». | Что | Где | | --- | --- | | Время | `store.Now()` — единственный источник, всегда UTC; формат хранения — RFC 3339 | | Идентификаторы | `internal/ident` — генерация и нормализация ULID; `ident.Parse` на каждой входной границе | | Целевые имена и превью раскладки | `internal/naming` — одна логика для превью в UI и для реального применения | | Разбор источника | `internal/magnet` и `internal/torrent`; инфохэш извлекается только здесь | | Приём | use-case `ingest` — общий путь для HTTP, веб-UI, Telegram и CLI | | Переходы состояний | `worker` под per-download блокировкой; легальность перехода задаётся декларативным графом | | Хардлинки и удаление своих ссылок | `internal/layout` — единственное место, которое пишет в файловую систему библиотеки | | Трансляция доменной ошибки в код ответа | внешняя граница транспорта (`httpapi`, `tgbot`); правило — [conventions/errors.md](conventions/errors.md) | | Логирующий чекпоинт | доменная граница, один на операцию; правило — [conventions/logging.md](conventions/logging.md) | | Настройки | один TOML-файл, валидируется на старте; образец `config.example.toml` — источник истины по полям | ## Деплой Работает в docker в одной среде с qBittorrent и Jellyfin — см. [ADR-2026-07-24-local-image-build](adr/ADR-2026-07-24-local-image-build.md). Сборка: статический бинарь (`GOOS=linux GOARCH=amd64 CGO_ENABLED=0`) и **полный образ** собираются локально на control-хосте (`task image` упаковывает бинарь в `distroless/static`). Образ едет на сервер через `docker save`/`load` (роль `app_image` в umbar), там и запускается. Go-тулчейн и `docker build` на сервере не нужны. Разделение ответственности: **jellybit** (этот репозиторий) даёт бинарь и `Dockerfile`; **umbar** — оркестрацию (доставка, docker compose, `playbook-jellybit.yml`, рендер секретов). Параметры запуска: - **Общая docker-сеть** (external, напр. `media-net`) — адресация по именам (`http://qbit:8989`, `http://jellyfin:8096`). Веб-UI публикуется на хост (`8080:8080`) для LAN. qBit валидирует Host-заголовок — в umbar выставлен `WebUI\ServerDomains=*`; LLM на хосте достаётся через `host.docker.internal`. - **`user: "1000:1000"`**, UMASK 022 — единый системный пользователь umbar. - **mount `/srv/media`** — единая песочница (см. ниже). - **mount конфига** `/srv/applications/jellybit/config` → `/config` (ro), `config.toml` с правами `0600`; рендерится плейбуком umbar, бекапу не подлежит. - **mount данных** `/srv/applications/jellybit/data` → `/data`, SQLite `/data/jellybit.db`. **Бекапить обязательно** — без него редеплой стирает всё in-flight состояние. - **healthcheck** зовёт сам бинарь (`jellybit healthcheck`): в distroless нет shell и curl. ### Единая песочница `/srv/media` Весь медиа-стек лежит под одним каталогом и монтируется **идентично** (`/srv/media:/srv/media`) во все медиа-приложения: ``` /srv/media/ incomplete/ ← qBit качает сюда downloads/ ← готовые раздачи (источник хардлинка) movies/ series/ ← библиотека Jellyfin (цель хардлинка) ``` Так как всё под одним mount'ом, работают и **хардлинк** (downloads → movies/series), и **мгновенный move** qBit (incomplete → downloads) — границ между точками монтирования (`EXDEV`) нет. Путь из qBittorrent уже равен хост-пути, трансляция не нужна (`path_map` — фолбэк, обычно пуст). Секреты и чужие приложения (`/srv/applications`) в песочницу не попадают. Библиотеки Jellyfin указывают на `movies`/`series`, а не на корень — иначе в индекс попадут `downloads`/`incomplete`. ## Открытые вопросы - Пока нет. Решённое разъехалось по ADR и capability-спекам; то, что требует работы, живёт задачами в [tasks/BACKLOG.md](tasks/BACKLOG.md).