# Архитектура

Обзор: как сложено и где что работает. **Поведение системы здесь не
описывается** — его нормативный дом `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`; действия пользователя идут командами к `worker`. Перечень команд
и их эффекты — нормативно в [review](../openspec/specs/review/spec.md), пути
закрытия и удаления — в
[state-reconciliation](../openspec/specs/state-reconciliation/spec.md).

## Внешние границы и форматы

- **qBittorrent WebUI API** — единственный способ качать: источник **отдаём
  ему**, сами по пользовательскому URL не ходим (SSRF исключён). Какие виды
  источника принимаются и как разбираются —
  [ingest](../openspec/specs/ingest/spec.md); способ добавления по
  `source_type` — [download-tracking](../openspec/specs/download-tracking/spec.md);
  откуда берутся пути файлов —
  [file-layout](../openspec/specs/file-layout/spec.md).
- **LLM** — OpenAI-совместимый Chat Completions за интерфейсом (`[llm].type`);
  контракт вызова, формат вывода и разбор ответа —
  [recognition](../openspec/specs/recognition/spec.md).
- **Метабазы** — TMDB, TVDB, TVMaze (последняя без ключа, только сериалы).
- **Jellyfin** — HTTP-триггер пересканирования медиатеки, опционален; когда он
  дёргается и по каким переходам —
  [file-layout](../openspec/specs/file-layout/spec.md).
- **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 — период в
  [database.md](database.md) → «Настройки с числовым значением» — и
  периодическая сверка) плюс редкие события по запросу человека. Объём —
  единицы загрузок в день, десятки одновременно; ориентир масштаба и его аудит —
  задача в беклоге.

## Единые точки проекта

Материал для вопроса «не появился ли второй способ делать то, что уже делается».

| Что | Где |
| --- | --- |
| Время | `store.Now()` — единственный источник меток времени в данных, всегда UTC; формат хранения — RFC 3339. Вторая санкционированная точка wall-clock — timestamp-часть ULID в `ident.NewID` (исключение `^internal/(ident\|store)/` в `.golangci.yml`). Отдельно от меток в данных стоят замеры длительности: `cmd/jellybit` исключён из `forbidigo` целиком (правило `^cmd/`), плюс точечные `//nolint:forbidigo` в `internal/logging/ext.go` и `internal/httpapi/httpapi.go` |
| Идентификаторы | `internal/ident` — генерация и нормализация ULID; `ident.Parse` на каждой входной границе |
| Целевые имена и превью раскладки | `internal/naming` — одна логика для превью в UI и для реального применения |
| Чистка человекочитаемых значений | три санитайзера с разным предметом, сводить их в один нельзя: `recognize.SanitizeTitle` — значение (недоверенный вход: LLM и метабазы), `layout.sanitizeComponent` — компонент пути под требования ФС, `naming.sanitize` — отображаемый ярлык. Значение метабазы чистится **на каждой** точке входа в план: сборка матча, копия кандидата для ревью, набор закреплённых значений источника и его чтение — [ADR-2026-08-10-sanitize-at-every-entry](adr/ADR-2026-08-10-sanitize-at-every-entry.md) |
| Разбор источника | `internal/magnet` и `internal/torrent`; инфохэш извлекается только здесь |
| Приём | use-case `ingest` — общий путь для HTTP, веб-UI, Telegram и CLI |
| Переходы состояний | `worker` под per-download блокировкой; легальность перехода задаётся декларативным графом |
| Хардлинки и удаление своих ссылок | `internal/layout` — единственное место, которое пишет в файловую систему библиотеки |
| Построение и проверка целевого пути | `layout.BuildLinks` — единственная сборка пути; там же обе проверки, и порядок значим: нахождение под корнем библиотеки, затем длина компонента. Отсюда же строятся оба предпросмотра ревью, поэтому показанное и применённое совпадают устройством, а не договорённостью |
| Причина, по которой человек не видит плана | считается **на показе** (`worker.ReviewData.PreviewError`) и предпочитается записанной в состоянии: записанной может не быть вовсе, а после смены источника она уже про другой план — [ADR-2026-08-10-reason-computed-on-read](adr/ADR-2026-08-10-reason-computed-on-read.md) |
| Условие допуска полного удаления | `store.State.CanDelete()` — «из этого состояния удаление с файлами разрешено»; своего перечня состояний не заводит ни один транспорт (веб-UI, Telegram, страница группового удаления), а проверку в ядре предикат не заменяет: допуск держится без транспорта |
| Условие самообновления веб-UI | `store.State.IsObservable()` — «состояние ещё может измениться без человека»; транспорт своего перечня состояний не заводит, а поверхность (карточка списка, страница загрузки) держит **ровно один** поллер на обновляемый корень — [ADR-2026-08-10-observability-is-not-terminality](adr/ADR-2026-08-10-observability-is-not-terminality.md), правило разметки — [conventions/web-ui.md](conventions/web-ui.md) |
| Трансляция доменной ошибки в код ответа | внешняя граница транспорта (`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`.

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

Места, где устройство знаемо тонкое: не дефекты, а принятые пока пробелы. Здесь
только адрес и одна фраза — что именно не сделано; работа под каждым живёт
задачей в [tasks/BACKLOG.md](../tasks/BACKLOG.md). Список нужен ревью: правка,
попавшая в такую область, стоит дороже, чем выглядит.

| Область | Чего нет сегодня | Задача |
| --- | --- | --- |
| Масштаб | ориентир 100/1000 загрузок не зафиксирован, узкие места SQLite, воркера и поллинга не измерены | `scale-100-downloads` |
| Ретеншен | терминальные задачи и сырые ответы LLM копятся вечно, авточистки нет | `db-retention-cleanup` |
| Бекап | бекапить `/data` требуется, а стратегия и ротация не описаны | `sqlite-backup` |
| Метрики и алертинг | healthcheck проверяет только сам сервис; метрик и алертинга нет, отказ виден по застрявшей задаче | `deep-healthcheck-dependencies` |
| Идентичность раздачи | split v1/v2-хеши не связаны, паре `xt` из магнета доверяем | `infohash-identity-integrity` |
| Расход внешних лимитов | кэша ответов метабаз нет, повтор распознавания бьёт провайдера заново | `metadata-cache` |
| История переходов | хранится только текущее состояние, «как сюда попали» восстанавливается по логам | `download-transition-history` |
| Предел ответа LLM | лимита на размер ответа нет — единственный недоверенный канал без предела; задачей пока не заведено | — |
