Правки после ревью документации

This commit is contained in:
2026-06-13 21:47:30 +03:00
parent 547940ea59
commit 34bd2a5b5f
10 changed files with 419 additions and 162 deletions
+12
View File
@@ -25,6 +25,18 @@
- **Минимум компонентов** — в духе umbar, без зоопарка сервисов. Внешние - **Минимум компонентов** — в духе umbar, без зоопарка сервисов. Внешние
базы метаданных (TMDB/TVDB) опциональны, включаются конфигом. базы метаданных (TMDB/TVDB) опциональны, включаются конфигом.
## Инварианты (безопасность данных)
- **Источник неприкосновенен:** только `mkdir` / `link(2)` / `unlink`
своих ссылок; никогда не трогаем файлы под `paths.downloads`.
- **Целевой путь санитизируется** и проверяется, что он строго под
`paths.movies`/`series` (защита от traversal); существующее не
перезаписываем.
- **Выход LLM недоверенный** — безопасность на валидации пути, не на
промпте. Авто-раскладка только при подтверждённом матче в базе.
- **Запуск:** контейнер под `1000:1000`, `network_mode: host`, mount
`/srv` + data-том для SQLite/конфига.
## Документация: три раздела ## Документация: три раздела
- `docs/specs/`**живые** спецификации целевого состояния. Меняем по - `docs/specs/`**живые** спецификации целевого состояния. Меняем по
+39
View File
@@ -0,0 +1,39 @@
# Docker как единица деплоя, образ собирается на сервере
- Дата: 2026-06-13
## Контекст
jellybit — статический Go-бинарь (см.
[ADR-2026-06-13-go-single-binary](ADR-2026-06-13-go-single-binary.md)). На
сервере umbar qBittorrent и (в планах) Jellyfin работают в docker. Нужно
выбрать, как запускать jellybit и как доставлять его на сервер.
## Рассмотренные варианты
- **Нативный systemd-юнит** — ближе всего к «просто скопировать бинарь»,
но это отдельная среда вне docker; разнобой со смежными приложениями
(сети, монтирования, политики рестарта).
- **Docker, образ собран и запушен из CI/реестра** — каноничнее, но в
домашней лаборатории это лишний реестр и пайплайн.
- **Docker, образ собирается на сервере из готового бинаря** — на сервер
кладутся бинарь + `Dockerfile`, `docker build` выполняется на месте.
## Решение
jellybit запускаем в **docker** — в одной среде с qBittorrent/Jellyfin
(единый способ управления, сети, монтирований). Образ **собираем на
сервере** из доставленного бинаря и `Dockerfile` (копирует бинарь в
`distroless/static`). Go-тулчейн и реестр на сервере не нужны. `Dockerfile`
(упаковка) живёт в jellybit; оркестрация (доставка, build, compose с
`network_mode: host`, `user 1000:1000`, mount `/srv` и data-тома) — в
umbar.
## Последствия
- `+` Одна среда со смежными сервисами; единые сети/монтирования/политики.
- `+` Доставка остаётся дешёвой: бинарь + `Dockerfile`, без реестра.
- `+` Образ версионируется (тег по сборке) — есть откат.
- `-` Шаг `docker build` на сервере (на Intel N150 дёшево).
- `-` Лёгкое отступление от «просто скопировать бинарь» — оправдано
единообразием среды с qBittorrent/Jellyfin.
+42
View File
@@ -0,0 +1,42 @@
# Хардлинки вместо копирования и симлинков
- Дата: 2026-06-13
## Контекст
jellybit раскладывает скачанные qBittorrent'ом файлы в библиотеку
Jellyfin. Два требования тянут в разные стороны: раздача должна
продолжаться (источник неприкосновенен), а место на диске — не
дублироваться. qBittorrent пишет в `/srv/downloads`, Jellyfin читает
`/srv/media` — обе ветки на одной ФС.
## Рассмотренные варианты
- **Хардлинк** — второе имя того же inode в `/srv/media`. Плюсы: ноль
доп. места, раздача цела, файл «настоящий» для Jellyfin. Минусы: только
в пределах одной ФС; нельзя линковать каталоги (только файлы).
- **Копирование** (поведение radarr/sonarr по умолчанию) — дублирует
десятки ГБ на каждый релиз; для домашнего сервера дорого и медленно.
- **Симлинк** — место экономит, но ломается при перемещении источника,
Jellyfin/плееры иногда плохо дружат с символическими ссылками, а
удаление раздачи рвёт библиотеку.
- **Перемещение** — убивает раздачу (сид, ratio) и нарушает «источник
неприкосновенен».
## Решение
Раскладываем **хардлинками**. На одной ФС (`/srv`) это бесплатно по месту,
раздача продолжается, файл неотличим от обычного. Линкуем только файлы,
целевые каталоги создаём `mkdir`. Жёсткий инвариант: jellybit никогда не
перемещает и не удаляет исходные файлы; undo удаляет только свои ссылки
под `/srv/media`.
## Последствия
- `+` Ноль дублирования, мгновенно, раздача цела.
- `+` Простая и безопасная модель операций: только add-link и
remove-own-link.
- `-` Требуется одна ФС — внутри docker обеспечивается монтированием
общего родителя `/srv` (иначе `link(2)` даёт `EXDEV`).
- `-` Каталоги хардлинковать нельзя — раскладка пофайловая, целевые папки
создаём сами (0755, владелец 1000:1000).
+2
View File
@@ -56,4 +56,6 @@
| Дата | Запись | Статус | | Дата | Запись | Статус |
| ---------- | ---------------------------------------------------------------- | ------ | | ---------- | ---------------------------------------------------------------- | ------ |
| 2026-06-13 | [Docker как единица деплоя](ADR-2026-06-13-docker-deploy.md) | — |
| 2026-06-13 | [Хардлинки вместо копирования и симлинков](ADR-2026-06-13-hardlinks.md) | — |
| 2026-06-13 | [Go и доставка одним бинарём](ADR-2026-06-13-go-single-binary.md) | — | | 2026-06-13 | [Go и доставка одним бинарём](ADR-2026-06-13-go-single-binary.md) | — |
+4 -2
View File
@@ -31,8 +31,10 @@ qBittorrent. Решим по опыту эксплуатации.
## Доступ к веб-UI ## Доступ к веб-UI
Сейчас предполагается доверенная локальная сеть. Если понадобится — Решено для v1: без авторизации в доверенной LAN, опц. allowlist подсетей
простая авторизация или вынос за reverse-proxy с аутентификацией. (`http.trusted_subnets`) — как умеет qBittorrent. На будущее, если
понадобится защита: токен/Basic в самом приложении или вынос за
reverse-proxy с аутентификацией.
## Повторный прогон распознавания ## Повторный прогон распознавания
+11 -7
View File
@@ -10,15 +10,18 @@
копирует готовый бинарь), golangci-lint, lefthook. Документация (этот копирует готовый бинарь), golangci-lint, lefthook. Документация (этот
этап — частично готов). этап — частично готов).
- **Ф1 — ingest + tracking (без LLM).** `Ingest()` + добавление в - **Ф1 — ingest + tracking (без LLM).** `Ingest()` + добавление в
qBittorrent (категория `jellybit`) + `worker`-поллинг завершения + qBittorrent (источник отдаём ему, категория `jellybit`, ключ
машина состояний. Наружу: HTTP API, список в веб-UI, `jellybit add`. идемпотентности по infohash) + `worker`-поллинг завершения (трансляция
`path_map`) + машина состояний. Наружу: HTTP API, список в веб-UI,
`jellybit add`.
- **Ф2 — распознавание.** `go-ptn` + LLM (structured output) → план + - **Ф2 — распознавание.** `go-ptn` + LLM (structured output) → план +
оценка уверенности. Без записи на диск. оценка уверенности. Без записи на диск.
- **Ф3 — раскладка + минимальный review.** Хардлинки по конвенциям - **Ф3 — раскладка + минимальный review.** Хардлинки по конвенциям
Jellyfin, субтитры, идемпотентность, **undo**. Авто при высокой Jellyfin (санитизация пути, never-overwrite), субтитры, идемпотентность,
уверенности; низкая → review (htmx): подсказка + перераспознавание, из **undo**. Авто только при матче в базе и чистой валидации; иначе → review
ручного — тип, выбор кандидата базы, пометка «игнор». Полный редактор (htmx): подсказка + перераспознавание, из ручного — тип, выбор кандидата
маппинга — Ф5. См. [review-ux.md](../specs/review-ux.md). базы, пометка «игнор». Полный редактор маппинга — Ф5. См.
[review-ux.md](../specs/review-ux.md).
- **Ф4 — метаданные.** TMDB/TVDB опционально, provider-id в именах, - **Ф4 — метаданные.** TMDB/TVDB опционально, provider-id в именах,
валидация распознавания против числа серий. валидация распознавания против числа серий.
- **Ф5 — Telegram + UX.** Бот-адаптер + парсер сообщений торрент-бота, - **Ф5 — Telegram + UX.** Бот-адаптер + парсер сообщений торрент-бота,
@@ -27,7 +30,8 @@
нотификации. нотификации.
- **Ф6 — деплой.** Сборка статического бинаря здесь; доставка бинаря + - **Ф6 — деплой.** Сборка статического бинаря здесь; доставка бинаря +
`Dockerfile` на сервер, `docker build` и запуск на месте; оркестрация — `Dockerfile` на сервер, `docker build` и запуск на месте; оркестрация —
`playbook-jellybit.yml` в umbar. `playbook-jellybit.yml` в umbar: `network_mode: host`, `user 1000:1000`,
mount `/srv` + data-том `/srv/applications/jellybit/data`, healthcheck.
## Заметки по порядку ## Заметки по порядку
+195 -87
View File
@@ -11,109 +11,157 @@ qBittorrent, определяет содержимое (фильм или сер
- **Один статический бинарь.** Доставка — копированием на сервер. См. - **Один статический бинарь.** Доставка — копированием на сервер. См.
[ADR-2026-06-13-go-single-binary](../adr/ADR-2026-06-13-go-single-binary.md). [ADR-2026-06-13-go-single-binary](../adr/ADR-2026-06-13-go-single-binary.md).
- **Источник не трогаем.** В библиотеку кладём хардлинки; qBittorrent - **Источник неприкосновенен** (жёсткий инвариант). jellybit делает
продолжает раздачу, место на диске не дублируется. только `mkdir`, `link(2)` и `unlink` *своих* целевых ссылок (для undo).
- **Единое ядро, тонкие транспорты.** Вся логика приёма загрузки — в Никогда не `unlink`/`rename` под `paths.downloads`. См.
use-case `Ingest`. HTTP API, веб-UI и Telegram — обёртки над ним. [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) - **Опциональные внешние зависимости.** Базы метаданных (TMDB/TVDB)
включаются конфигом; без них сервис работает на одном LLM. включаются конфигом; без них сервис работает на одном LLM, но
авто-раскладка без матча в базе не делается (см. recognition.md).
- **Минимум компонентов.** В духе umbar — без лишних сервисов. - **Минимум компонентов.** В духе umbar — без лишних сервисов.
## Компоненты ## Компоненты
| Пакет | Ответственность | | Пакет | Ответственность |
| ----------- | ----------------------------------------------------- | | ----------- | -------------------------------------------------------- |
| `ingest` | use-case приёма загрузки, общий для всех транспортов | | `ingest` | use-case приёма загрузки, общий для всех транспортов |
| `qbt` | клиент qBittorrent WebUI API | | `qbt` | клиент qBittorrent WebUI API (сессия, добавление, опрос) |
| `worker` | фоновый цикл: машина состояний, поллинг завершения | | `worker` | владелец машины состояний; поллинг, сериализация команд |
| `recognize` | пред-парс имени + вызов LLM + модель уверенности | | `recognize` | пред-парс имени + вызов LLM + модель уверенности |
| `llm` | провайдер LLM за интерфейсом (дискриминатор `type`) | | `llm` | провайдер LLM за интерфейсом (дискриминатор `type`) |
| `metadata` | интерфейс баз метаданных + TMDB/TVDB (опц.) | | `metadata` | интерфейс баз метаданных + TMDB/TVDB (опц.) |
| `layout` | конвенции Jellyfin + хардлинкер | | `layout` | конвенции Jellyfin, санитизация путей, хардлинкер, undo |
| `store` | SQLite: загрузки, распознавание, ссылки | | `store` | SQLite: загрузки, распознавание, подсказки, ссылки |
| `httpapi` | REST + веб-UI (server-rendered, htmx) | | `httpapi` | REST + веб-UI (server-rendered, htmx) |
| `tgbot` | Telegram-адаптер + парсер сообщений торрент-бота | | `tgbot` | Telegram: приём + парсер сообщений бота + исходящие пинги |
| `config` | загрузка TOML-конфига | | `config` | загрузка TOML-конфига |
## Поток и машина состояний ## Поток и машина состояний
``` ```
ingest → downloading → completed → recognizing ─┬─ уверенно ───────→ linking → done ingest → downloading → completed → recognizing ─┬─ авто ────────────────→ linking → done
└─ сомнительно → review → linking → done └─ review ⇄ recognizing ─→ linking → done
любой шаг при ошибке → failed │ │ └─ moving/checking (ещё не готов)
│ └─ stuck (не качается дольше таймаута)
└─ failed ⇄ retry
done → undo → reverted
review → «Позже» → deferred → review
любой → «Отклонить» → cancelled
``` ```
- **ingest** — приняли источник + контекст, поставили в qBittorrent - **ingest** — приняли источник + контекст, отдали в qBittorrent
(категория `jellybit`), записали в БД. (категория `jellybit`), записали в БД с ключом идемпотентности.
- **downloading / completed** — `worker` поллит qBittorrent по категории - **downloading / completed** — `worker` поллит qBittorrent по категории
(интервал `worker.poll_interval`, по умолчанию 5 с). (`worker.poll_interval`, 5 с). Готовность — только когда файлы на месте
- **recognizing** — `recognize` строит план раскладки и оценку (не `moving`/`checking*`), см. «Завершение в qBittorrent».
уверенности (см. [recognition.md](recognition.md)). - **recognizing** — `recognize` строит план и оценку уверенности
- **review** — план уходит человеку (веб-UI / Telegram), ждём решения; ([recognition.md](recognition.md)). Невалидный/непарсящийся ответ LLM →
сценарии — в [review-ux.md](review-ux.md). review (не failed).
- **linking** — `layout` создаёт хардлинки в библиотеке. - **review** — план уходит человеку ([review-ux.md](review-ux.md)); цикл
- **done** — опционально дёргаем скан библиотеки Jellyfin. `review ⇄ recognizing` — перераспознавание по подсказке.
- **linking** — `layout` создаёт хардлинки; идемпотентно, батчем.
- **done** — опционально дёргаем скан Jellyfin; доступен **undo**
`reverted` (убрать созданные ссылки).
- **deferred / cancelled / failed / stuck** — «Позже», «Отклонить»,
ошибка (ретраибельна), не качается дольше таймаута.
Состояние персистентно в SQLite — перезапуск сервиса безопасен, `worker` Все переходы и команды идут через `worker` под per-download блокировкой —
продолжает с того же места. два транспорта не гонятся за одно состояние. Состояние персистентно в
SQLite; на старте `worker` сверяет категорию qBittorrent с БД и
продолжает.
## Транспорты ## Транспорты
Все три ведут в один `Ingest(req)`: Все ведут в один `Ingest(req)`; действия пользователя (apply / refine /
reject / defer / undo) — команды к `worker`:
- **HTTP API + веб-UI** — форма «добавить», список загрузок, экран - **HTTP API + веб-UI** — форма «добавить», список, экран ревью
подтверждения раскладки (server-rendered + htmx, без JS-сборки). (server-rendered + htmx). В v1 **без авторизации** (доверенная LAN), с
- **Telegram-бот** — переслать magnet или сообщение торрент-бота прямо в опциональным allowlist подсетей (`http.trusted_subnets`). Защиту
jellybit; текст становится контекстом распознавания. навесим позже — [drafts/ideas.md](../drafts/ideas.md).
- **Telegram-бот** — переслать magnet/сообщение бота; текст становится
контекстом. Доступ — по `telegram.allowed_user_ids` (пусто = запрет
всем, fail-closed). Бот же шлёт **пинги** о входе в review/готовности.
- **CLI** — `jellybit add <magnet> --context "..."` для отладки. - **CLI** — `jellybit add <magnet> --context "..."` для отладки.
Источник (magnet / `.torrent` / URL) **отдаём в qBittorrent** — он сам
скачивает; jellybit не делает исходящих запросов на пользовательский URL
(SSRF исключён).
## Хранилище ## Хранилище
SQLite, минимум таблиц: SQLite. Схема покрывает приём, цикл ревью и откат:
- `download` — источник, контекст, hash торрента, категория, состояние, - `download` `id`, тип и значение источника, контекст, `infohash`,
тайминги. `idempotency_key`, состояние, `error_code`/`error_msg`, тайминги.
- `recognition` — тип, название, год, сезон, provider-id, оценка (infohash может появиться позже приёма — для magnet без метаданных.)
уверенности, сырой ответ LLM. - `recognition` — попытки распознавания: `download_id`, `attempt_no`,
- `file_link` — соответствие исходный файл → целевой путь, вид `is_current`, тип, название, год, `provider` (`tmdb|tvdb|none`),
(видео/субтитры), статус. `provider_id`, `confidence`, причины-не-авто, сырой ответ LLM.
- `hint` — накопленные подсказки человека (`download_id`, текст, время).
- `override` — запиненные ручные правки полей (перераспознавание не
затирает).
- `metadata_candidate` — кандидаты базы для выбора (`recognition_id`,
provider, id, название, год, выбран ли).
- `file_link``download_id`, `apply_batch_id`, исходный → целевой путь,
вид (видео/субтитры/…), статус, время. Батч нужен для точечного undo.
Дубликат `infohash` при приёме — повторно не добавляем, ведём к
существующей загрузке (идемпотентность).
## Конфигурация ## Конфигурация
TOML. В репозитории — `config.example.toml` с placeholder'ами; реальный TOML. В репозитории — `config.example.toml` с placeholder'ами; реальный
`config.toml` рендерится при деплое Ansible-шаблоном из переменных umbar `config.toml` рендерится при деплое Ansible-шаблоном из переменных umbar
(секреты — в `vars/secrets.yml` под ansible-vault) и не коммитится. (секреты — `vars/secrets.yml` под ansible-vault), на диске **0600**,
Пример: владелец `1000:1000`, не коммитится. Пример:
```toml ```toml
[qbittorrent] [qbittorrent]
url = "http://127.0.0.1:8989" url = "http://127.0.0.1:8989" # работает при network_mode: host
username = "admin" username = "admin"
password = "" password = ""
category = "jellybit" category = "jellybit"
# qBit отдаёт пути своего контейнера ("/downloads/…"); транслируем в хост-путь
path_map = { "/downloads" = "/srv/downloads" }
[paths] [paths]
# хост-пути (видны внутри контейнера jellybit через mount /srv)
downloads = "/srv/downloads" downloads = "/srv/downloads"
movies = "/srv/media/movies" movies = "/srv/media/movies"
series = "/srv/media/series" series = "/srv/media/series"
[llm] [llm]
# type — дискриминатор реализации; пока поддерживается "openai-compat" # type — дискриминатор реализации; пока поддерживается "openai-compat"
type = "openai-compat" type = "openai-compat"
base_url = "http://127.0.0.1:1234/v1" base_url = "http://127.0.0.1:1234/v1"
api_key = "" api_key = ""
model = "qwen2.5-32b-instruct" model = "qwen2.5-32b-instruct"
timeout = "120s"
max_retries = 3 # непарсящийся ответ после ретраев → review
[metadata.tmdb] [metadata.tmdb]
enabled = true enabled = false # включается ключом; без матча авто не делаем
api_key = "" api_key = ""
timeout = "10s"
[metadata.tvdb] [metadata.tvdb]
enabled = false enabled = false
api_key = "" api_key = ""
timeout = "10s"
[worker] [worker]
poll_interval = "5s" # как часто опрашивать qBittorrent poll_interval = "5s" # как часто опрашивать qBittorrent
stuck_after = "1h" # не качается дольше → stuck
magnet_timeout = "30m" # magnet без метаданных дольше → failed
[recognition] [recognition]
auto_confidence_threshold = 0.85 auto_confidence_threshold = 0.85
@@ -121,10 +169,11 @@ auto_confidence_threshold = 0.85
[telegram] [telegram]
enabled = false enabled = false
token = "" token = ""
allowed_user_ids = [] allowed_user_ids = [] # пусто = запрет всем (fail-closed)
[http] [http]
listen = ":8080" listen = ":8080"
trusted_subnets = [] # опц. allowlist подсетей; пусто = без ограничений
[log] [log]
level = "info" level = "info"
@@ -133,36 +182,91 @@ format = "json"
## Логирование ## Логирование
Структурированный JSON через `log/slog`. Каждая загрузка проходит со Структурированный JSON через `log/slog`, в stdout (docker подбирает).
сквозным идентификатором; решения распознавания (почему авто/ревью) Каждая загрузка проходит со сквозным идентификатором; решения
логируются явно. распознавания (почему авто/ревью) и операции с файлами логируются явно.
## Завершение в qBittorrent
`worker` опрашивает qBittorrent по категории и сопоставляет состояния:
- **готово к раскладке:** `uploading`/`stalledUP`/`pausedUP`/`queuedUP`/
`forcedUP` — и **только** когда нет `moving`/`checkingUP`.
- **ещё качается:** `downloading`/`stalledDL`/`metaDL`/`queuedDL`/
`checkingDL`/`forcedDL`.
- **застряло:** `metaDL` дольше `magnet_timeout`, `stalledDL` дольше
`stuck_after``stuck`/`failed`.
- **ошибка:** `error`/`missingFiles``failed`.
Пути файлов берём из API (`save_path`/`content_path` + относительные
имена), не из константы, и транслируем `path_map`. Предполагаем, что
отдельный «incomplete»-каталог в qBittorrent выключен (иначе путь до
завершения иной).
## Раскладка файлов
`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` — источник недосягаем.
- **Одна ФС обязательна.** `link(2)` через границы ФС даёт `EXDEV`
падаем с понятной ошибкой; по построению этого не должно случаться.
### Пути и контейнеры (три namespace)
`/srv/downloads` и `/srv/media` — одна ФС на хосте (подтверждено), но
путь существует в трёх пространствах имён:
- **контейнер qBittorrent** видит только `/downloads` (= хост
`/srv/downloads`); `/srv/media` он не монтирует — и не должен.
- **контейнер jellybit** монтирует **общего родителя `/srv`** одним
bind-mount'ом — так `downloads` и `media` гарантированно на одной ФС
внутри него, и хардлинк проходит.
- **хост** — где обе ветки реально на одном томе.
Поэтому путь из qBittorrent (`/downloads/…`) транслируется в хост-путь
(`/srv/downloads/…`) по `qbittorrent.path_map`, и уже он используется как
источник хардлинка.
## Деплой ## Деплой
Jellybit работает в **docker** — в одной среде с qBittorrent и Jellyfin Jellybit работает в **docker** — в одной среде с qBittorrent и Jellyfin
(они тоже в контейнерах на umbar). Единая среда запуска перевешивает (см. [ADR-2026-06-13-docker-deploy](../adr/ADR-2026-06-13-docker-deploy.md)).
простоту нативного systemd. Сборка: статический бинарь (`GOOS=linux GOARCH=amd64 CGO_ENABLED=0`,
сервер на Intel N150) собирается здесь; на сервер во временную build-папку
кладутся бинарь + `Dockerfile` (копирует бинарь в `distroless/static`),
образ собирается на месте и запускается. Go-тулчейн на сервере не нужен.
Сборка — дёшево и сердито: статический бинарь собирается здесь; на сервер Параметры запуска (в umbar-compose):
во временную build-папку кладутся бинарь + `Dockerfile` (он просто
копирует бинарь в минимальный образ), образ собирается прямо на сервере и - **`network_mode: host`** — чтобы `127.0.0.1:8989` достучался до
запускается. Go-тулчейн на сервере не нужен — только docker. qBittorrent (он на bridge с проброшенным портом; из отдельного
bridge-контейнера `127.0.0.1` — это он сам). Заодно `:8080` слушает на
хосте. Прецедент в umbar — glances.
- **`user: "1000:1000"`**, UMASK 022 — единый системный пользователь
umbar; созданные каталоги 0755, файлы-ссылки наследуют inode источника.
- **mount `/srv`** (общий родитель) — для хардлинков (см. выше).
- **mount данных** `/srv/applications/jellybit/data``/data`: SQLite
(`/data/jellybit.db`) и `config.toml`. Без него редеплой стёр бы всё
in-flight состояние.
- **healthcheck** на `/healthz`.
Разделение ответственности: Разделение ответственности:
- **jellybit** (этот репозиторий) — производит статический бинарь и - **jellybit** (этот репозиторий) — статический бинарь и `Dockerfile`.
`Dockerfile`. - **umbar** — оркестрация: доставка артефактов, `docker build`, запуск
- **umbar** — оркестрация деплоя: доставка артефактов, `docker build` и через docker compose (`playbook-jellybit.yml`) с параметрами выше.
запуск через docker compose (`playbook-jellybit.yml`).
## Раскладка файлов
Хардлинки в `paths.movies` / `paths.series` по конвенциям Jellyfin.
Детали и крайние случаи — в [jellyfin-layout.md](jellyfin-layout.md).
`/srv/downloads` и `/srv/media` — одна ФС (подтверждено), поэтому
хардлинки применимы. Так как jellybit в docker (см. «Деплой»), контейнеру
монтируем общего родителя `/srv` — чтобы внутри оба каталога остались на
одной ФС и хардлинк проходил.
## Предполагаемая структура репозитория ## Предполагаемая структура репозитория
@@ -174,21 +278,25 @@ internal/
migrations/ миграции SQLite migrations/ миграции SQLite
web/templates/ шаблоны веб-UI web/templates/ шаблоны веб-UI
docs/ specs / adr / drafts docs/ specs / adr / drafts
config.example.toml Dockerfile .dockerignore config.example.toml
``` ```
## Решённые вопросы ## Решённые вопросы
- `/srv/downloads` и `/srv/media` — одна ФС (подтверждено); хардлинки - Пути/контейнеры — три namespace сведены: qBit отдаёт `/downloads`,
применимы. транслируем в хост через `path_map`; jellybit монтирует `/srv`.
- Детект завершения — поллинг qBittorrent раз в несколько секунд - Сеть jellybit↔qBittorrent — `network_mode: host`.
(`worker.poll_interval`). Webhook — возможная оптимизация на будущее - Состояние — на persistent-томе `/srv/applications/jellybit/data`.
([drafts/ideas.md](../drafts/ideas.md)). - Детект завершения — поллинг; webhook — на будущее (drafts/ideas).
- Секреты — в переменных umbar; `config.toml` рендерится Ansible-шаблоном - Источник (magnet/URL/.torrent) отдаём в qBittorrent — без SSRF.
при деплое. - Авто-раскладка требует подтверждённого матча в базе; иначе review.
- Форма запуска — **docker**, образ собирается на сервере из готового - Веб-UI в v1 без авторизации (доверенная LAN, опц. allowlist подсетей).
бинаря (см. «Деплой»). - Форма запуска — docker, образ собирается на сервере; контейнер под
`1000:1000`, `network_mode: host`, mount `/srv` + data-том.
## Открытые вопросы ## Открытые вопросы
Существенных пока нет. - Конфигурация Jellyfin (URL + API-ключ) и триггер скана — когда Jellyfin
будет развёрнут в umbar (сейчас его там нет).
- Подтвердить, что «incomplete»-каталог qBittorrent выключен (иначе путь
файлов до завершения иной).
+27 -11
View File
@@ -18,7 +18,9 @@ movies/
- provider-id в имени папки (`[tmdbid-...]`) добавляется при работе с - provider-id в имени папки (`[tmdbid-...]`) добавляется при работе с
базой — снимает неоднозначность для русских названий, которые Jellyfin базой — снимает неоднозначность для русских названий, которые Jellyfin
иначе может опознать неверно. иначе может опознать неверно.
- Внешние субтитры — `Имя.<lang>.srt`, при необходимости `.forced`. - Внешние субтитры — `Имя.<lang>[.flag].srt` (флаги `forced`/`sdh`/
`default`/`hi`), напр. `…ru.forced.srt`; база имени совпадает с именем
видеофайла. Пары VobSub — `.idx` + `.sub`.
## Сериалы ## Сериалы
@@ -35,18 +37,32 @@ series/
## Сопоставление источник → цель ## Сопоставление источник → цель
qBittorrent держит файлы в `paths.downloads`. Для каждого распознанного Источник берём по пути из qBittorrent (`save_path`/`content_path` +
файла создаётся **хардлинк** в `paths.movies` / `paths.series` с целевым относительное имя, после трансляции `path_map` в хост-путь). Для каждого
именем. Исходный файл остаётся на месте (раздача продолжается), inode распознанного **файла** (не каталога) создаётся **хардлинк** в
общий — диск не дублируется. `paths.movies`/`paths.series`; целевые каталоги — `mkdir` (0755,
`1000:1000`). Исходный файл остаётся на месте (раздача продолжается),
inode общий — диск не дублируется.
Требование: целевой и исходный каталоги — на одной ФС. Целевое имя строится из распознанных полей и **санитизируется** (без
разделителей пути, `..`, управляющих символов); финальный путь обязан
быть строго под библиотекой. Существующую цель **не перезаписываем** (тот
же inode → готово; другой файл → коллизия → review). Инварианты и undo —
в [architecture.md](architecture.md) → «Раскладка файлов».
Требование: целевой и исходный каталоги — на одной ФС (внутри контейнера
jellybit это обеспечивает mount `/srv`).
## Крайние случаи ## Крайние случаи
- **Многофайловый фильм** (части) — `... part1`, `... part2` в одной - **Многофайловый фильм** (части) — стэкинг по точному токену Jellyfin
папке фильма. (`… - part1`/`cd1`); точный формат уточнить при реализации.
- **Сезон-пак** — все серии в один `Season xx`. - **Редакции** — `Имя (Год) [edition-Director's Cut]` либо отдельные
версии в папке фильма.
- **Двойная серия** в одном файле — `… SxxEyy-Eyy`.
- **Спецвыпуски** — `Season 00`.
- **Сезон-пак** — серии в один `Season xx`; смешанный пак — по per-file
сезонам.
- **Несколько аудиодорожек** — обычно внутри mkv, не наша забота. - **Несколько аудиодорожек** — обычно внутри mkv, не наша забота.
- **Аниме с абсолютной нумерацией** — требует пересчёта в S·E, отдельная - **Аниме с абсолютной нумерацией** — пересчёт в S·E, отдельная проработка
проработка ([drafts/ideas.md](../drafts/ideas.md)). ([drafts/ideas.md](../drafts/ideas.md)).
+75 -53
View File
@@ -2,91 +2,113 @@
## Задача ## Задача
По доступным сигналам определить: это фильм или сериал; каноническое По доступным сигналам определить: фильм или сериал; каноническое название
название и год; для сериала — сезон и соответствие файлов сериям; при и год; для сериала — сезон(ы) и соответствие файлов сериям; при включённых
включённых базах — provider-id. На выходе — план раскладки и оценка базах — провайдер и его id. На выходе — план раскладки, оценка уверенности
уверенности. и решение «авто или review».
## Сигналы ## Сигналы
- Имя торрента и структура каталогов. - Имя торрента и структура каталогов.
- Список файлов с размерами и расширениями. - Список файлов с размерами и расширениями. Абсолютный путь источника
- Текстовый контекст от человека. восстанавливаем как `save_path`/`content_path` из qBit (после трансляции
- Распарсенное сообщение торрент-бота (если пришло через Telegram): `path_map`) + относительное имя файла; учитываем одно- и многофайловые
название с годом, качество, переводы, magnet — см. пример в торренты.
[BRIEF.md](../../BRIEF.md). - Текстовый контекст человека (+ накопленные подсказки из review).
- Распарсенное сообщение торрент-бота (если через Telegram): название с
годом, качество, переводы — см. пример в [BRIEF.md](../../BRIEF.md).
**Все сигналы недоверенные** — имя торрента, сообщение бота и контекст
управляются извне и могут содержать инъекции. Выход LLM не отвечает за
безопасность: целевой путь всё равно санитизируется и проверяется на
выход за пределы библиотеки (см. architecture.md → «Раскладка файлов»).
## Конвейер ## Конвейер
1. **Пред-парс** имени релиза дешёвым парсером (`go-ptn`): черновые 1. **Пред-парс** имени релиза (`go-ptn`): черновые название/год/сезон/
название/год/сезон/серия и качество. Грубо, но бесплатно. серия и качество. Грубо, но бесплатно.
2. **LLM** (через провайдер-абстракцию, см. «Провайдер LLM»): получает 2. **LLM** (через провайдер-абстракцию, см. ниже): получает сигналы и
все сигналы и пред-парс, возвращает структурированный план в нашей пред-парс, возвращает структурированный план в нашей схеме. Хорошо
схеме. Хорошо справляется с русскими релиз-именами, чего не умеет берёт русские релиз-имена. Длинный список файлов усекаем/семплируем под
парсер. контекст модели.
3. **Сверка с базой** (опц., если включена TMDB/TVDB): подтверждаем 3. **Сверка с базой** (если включена TMDB/TVDB): ищем по названию+году,
название+год, берём официальный id и каноническое имя. берём официальный id и каноническое имя, собираем кандидатов.
4. **Оценка уверенности** и решение: авто-раскладка или ревью. 4. **Оценка уверенности** и решение: авто или review.
## Структура ответа LLM (черновик) ## Структура ответа LLM (предварительная)
``` ```
type movie | series type movie | series
title каноническое название title каноническое название
original_title оригинальное название (если есть) original_title оригинальное название (если есть)
year год year год
season номер сезона (для сериала) provider_hint строка для поиска в базе (НЕ итоговый id)
provider_hint подсказка для поиска в базе files[] { src, role: main|episode|subtitle|extra|sample|ignore,
files[] { src, role: main|episode|subtitle|extra|sample, season?, episode? } # season/episode — на файл
season?, episode? } confidence 0..1 — самооценка модели (вспомогательный сигнал)
confidence 0..1 — самооценка модели по полям
notes пояснения, неоднозначности notes пояснения, неоднозначности
``` ```
Сезон/серия — **на файле**: так выражаются мультисезонные паки,
спецвыпуски и смешанные раскладки; отдельного скалярного `season` нет.
`provider_hint` — только подсказка для поиска; итоговые `provider`
(`tmdb|tvdb|none`) и `provider_id` появляются после сверки с базой и
хранятся отдельно.
## Провайдер LLM ## Провайдер LLM
Доступ к LLM — за интерфейсом; конкретная реализация выбирается полем Доступ к LLM — за интерфейсом; реализация выбирается полем `[llm].type`
`[llm].type` в конфиге (дискриминатор). Это позволяет подключать (дискриминатор). Это позволяет подключать локальные модели и сторонние
локальные модели и сторонние (в т.ч. китайские) эндпоинты — ради экономии (в т.ч. китайские) эндпоинты — ради экономии и независимости от вендора.
и независимости от одного вендора.
- Первый и пока единственный тип — **`openai-compat`**: OpenAI-совместимый - Первый и пока единственный тип — **`openai-compat`**: OpenAI-совместимый
Chat Completions API (`base_url` + `api_key` + `model`). Под него Chat Completions API (`base_url` + `api_key` + `model`). Подходят
подходят локальные серверы (LM Studio, llama.cpp, Ollama) и облачные локальные серверы (LM Studio, llama.cpp, Ollama) и облачные совместимые
совместимые провайдеры (DeepSeek, Qwen и др.). провайдеры (DeepSeek, Qwen и др.).
- Структурированный вывод: запрашиваем JSON по нашей схеме - **Структурированный вывод надёжно:** просим JSON по схеме
(`response_format` со схемой там, где поддерживается; иначе json-режим (`response_format` со схемой где поддерживается; иначе json-режим или
или tool-call), **валидируем в Go** и ретраим при несоответствии — tool-call); на приёме срезаем ```-ограждения и извлекаем JSON,
серверы различаются по поддержке строгих схем. **валидируем в Go**, ретраим со схемой-в-промпте до `llm.max_retries`;
если так и не распарсилось — уходим в **review** (не в `failed`) с
причиной «ответ LLM не разобран». Серверы заметно различаются по
поддержке строгих схем, особенно мелкие локальные модели.
- Новые типы (напр. нативный `anthropic`) добавляются, не трогая - Новые типы (напр. нативный `anthropic`) добавляются, не трогая
`recognize`. `recognize`.
## Модель уверенности ## Модель уверенности
Авто-раскладка только если выполнено всё: Авто-раскладка только если выполнено **всё**:
1. **Самооценка LLM** ≥ порога (`recognition.auto_confidence_threshold`). 1. **Подтверждённый матч в базе** — единственный сильный результат
2. **Совпадение с базой** (если включена) — единственный сильный матч по TMDB/TVDB по названию+году, давший `provider_id`. **Нет матча (или
названию+году. база выключена) → всегда review.** Это и закрывает основной кейс
3. **Структурная валидация** проходит без предупреждений: (рус/аниме часто отсутствуют в базах), и снимает риск «LLM придумал».
- фильм: ровно один основной видеофайл (семплы/экстра отброшены); 2. **Структурная валидация** без предупреждений:
- сериал: число серий бьётся с базой (если есть), нумерация S·E - фильм: ровно один основной видеофайл (семплы/экстра/ignore отброшены);
консистентна, без пропусков и дублей. - сериал: число серий бьётся с базой, нумерация S·E консистентна, без
пропусков, дублей и неоднозначных спецвыпусков.
3. **Согласованность сигналов** — пред-парс (`go-ptn`) и LLM не
противоречат по типу/названию/году.
Иначе план уходит в **review** (сценарии — [review-ux.md](review-ux.md)). Самооценку LLM (`confidence`) учитываем как вспомогательный сигнал, но
На экране подтверждения всегда видно, *почему* не авто — это страховка на **не как единственный гейт**: она плохо откалибрована и поддаётся
дорогих файлах. инъекции. Решают матч в базе и валидация.
Иначе — **review** ([review-ux.md](review-ux.md)) с явной причиной.
## Что делаем с краёв ## Что делаем с краёв
- Семплы и «экстра» отбрасываем (эвристики по размеру/имени + LLM). - Семплы/«экстра»/мусор → роль `ignore` (эвристики размер/имя + LLM).
- Внешние субтитры (`.srt`, `.ass`) привязываем к видео и именуем по - Внешние субтитры (`.srt`, `.ass`, пары VobSub `.idx`+`.sub`) привязываем
Jellyfin (`*.ru.srt`). к видео и именуем по Jellyfin (`*.ru.srt`).
- Сезон-паки разбираем по сериям; аниме с абсолютной нумерацией — - Сезон-паки разбираем по сериям; смешанные паки, спецвыпуски (`Season
отдельный крайний случай, см. [drafts/ideas.md](../drafts/ideas.md). 00`), двойные серии (`SxxEyy-Eyy`) — через per-file season/episode;
любая неоднозначность → review.
- Аниме с абсолютной нумерацией — отдельный крайний случай, см.
[drafts/ideas.md](../drafts/ideas.md).
## На будущее ## На будущее
`go-ptn` слабее питоновского `guessit`. Если точности пред-парса не `go-ptn` слабее питоновского `guessit`. Если точности пред-парса не
хватит — завернуть `guessit` лёгким сервисом-спутником (один файл рядом хватит — завернуть `guessit` лёгким сервисом-спутником (один файл рядом с
с бинарём). См. [drafts/ideas.md](../drafts/ideas.md). бинарём). См. [drafts/ideas.md](../drafts/ideas.md).
+12 -2
View File
@@ -89,8 +89,14 @@ Fargo.S02.2015.WEB-DL.1080p.rus.eng 🟡 review
## Разделение труда ## Разделение труда
Telegram = одобрить / подсказать / выбрать кандидата / эскалировать в Telegram = одобрить / подсказать / выбрать кандидата / эскалировать в
веб. Веб = точные правки. Состояние ревью одно (в SQLite) — действовать веб. Веб = точные правки. Состояние ревью одно (в SQLite); команды из
можно из любого транспорта, последнее слово побеждает. любого транспорта сериализует `worker` под per-download блокировкой —
гонки двух транспортов нет, применяется последняя валидная команда.
**Доступ.** Telegram — по `telegram.allowed_user_ids` (пусто = запрет
всем). Веб-UI в v1 без авторизации (доверенная LAN), поэтому deep-link из
бота ведёт на открытую страницу — приемлемо по решению; защиту навесим
позже.
## Крайние сценарии ## Крайние сценарии
@@ -113,6 +119,10 @@ Telegram = одобрить / подсказать / выбрать кандид
- После «Применить» показываем, что создано. **Undo** — убрать созданные - После «Применить» показываем, что создано. **Undo** — убрать созданные
хардлинки одной кнопкой (источник цел); страховка от ошибочного хардлинки одной кнопкой (источник цел); страховка от ошибочного
подтверждения. подтверждения.
- **«Позже»** паркует загрузку в `deferred` (вернётся в review по
действию), **«Отклонить»** → `cancelled` (раскладку не делаем), **undo**
после применения → `reverted` (удаляет только ссылки своего батча, под
`media`). Полная карта состояний — в [architecture.md](architecture.md).
## Объём по версиям ## Объём по версиям