- Раскладка docs/ приведена к канону 2: заведены passport/architecture/ database/security/review и research; docs/specs, drafts, backlog, review/ и BRIEF.md разобраны и удалены, беклог переехал в docs/tasks (34 задачи, 6 целей, слаги на английский). - Нарративы specs удалены как дубли openspec-спек после поимённой сверки; остаток заведён задачами (редактор маппинга ревью, крайние случаи именования), отказ от сущности title промоутнут в ADR. - Проектные копии агентов и скиллов ревью/пайплайна удалены в пользу плагинов av-dev-pm и av-dev-pipeline; в task gate добавлен шаг canon вместо er-schema.
170 lines
15 KiB
Markdown
170 lines
15 KiB
Markdown
# Архитектура
|
||
|
||
Обзор: как сложено и где что работает. **Поведение системы здесь не
|
||
описывается** — его нормативный дом `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).
|