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

## Назначение

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 и авторизацию отложили — задача
  [«Авторизация веб-UI»](../backlog/avtorizaciya-web-ui.md) в беклоге.
- **Telegram-бот** — переслать magnet/сообщение бота; текст становится
  контекстом. Доступ — по `telegram.allowed_user_ids` (пусто = запрет
  всем, fail-closed). Бот же шлёт **пинги** о входе в review/готовности.
- **CLI** — `jellybit add <magnet> --context "..."` для отладки.

Источник (magnet / `.torrent` / URL) **отдаём в qBittorrent** — он сам
скачивает; jellybit не делает исходящих запросов на пользовательский URL
(SSRF исключён).

## Хранилище

SQLite. Полная схема (таблицы, поля, связи) — [database.md](database.md),
поддерживается вместе с миграциями. Схема покрывает приём, цикл ревью и
откат:

- `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

Когда наши библиотечные хардлинки меняются, `worker` неблокирующе просит Jellyfin
пересканировать медиатеку, чтобы плеер не держал битые пути и быстрее подхватил
новые файлы. Триггерят входы в `done` (файлы разложены), `reverted` (Undo снял
ссылки) и `deleted` (Delete снял ссылки / сверка констатировала их отсутствие) —
гейт по состоянию-цели в едином чекпоинте перехода, поэтому ловит и
пользовательские Undo/Delete, и reconcile-производный `deleted`. Промежуточный
рассинхрон (`target_missing`/`orphaned`) не сканируем — задача ждёт
relink/лечения. Включается конфигом `[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`/
  `reverted`/`deleted`; опц., включается `[jellyfin]`.
- Источник (magnet/URL/.torrent) отдаём в qBittorrent — без SSRF.
- Авто-раскладка требует подтверждённого матча в базе; иначе review.
- Веб-UI в v1 без авторизации (доверенная LAN, опц. allowlist подсетей).
- Форма запуска — docker, образ собирается на сервере; контейнер под
  `1000:1000`, в общей docker-сети, mount `/srv/media` + data-том.

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

- (пока нет)
