272 lines
20 KiB
Markdown
272 lines
20 KiB
Markdown
# Архитектура
|
||
|
||
## Назначение
|
||
|
||
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 <magnet> --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-том.
|
||
|
||
## Открытые вопросы
|
||
|
||
- (пока нет)
|