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

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, без зоопарка сервисов. Внешние
базы метаданных (TMDB/TVDB) опциональны, включаются конфигом.
## Инварианты (безопасность данных)
- **Источник неприкосновенен:** только `mkdir` / `link(2)` / `unlink`
своих ссылок; никогда не трогаем файлы под `paths.downloads`.
- **Целевой путь санитизируется** и проверяется, что он строго под
`paths.movies`/`series` (защита от traversal); существующее не
перезаписываем.
- **Выход LLM недоверенный** — безопасность на валидации пути, не на
промпте. Авто-раскладка только при подтверждённом матче в базе.
- **Запуск:** контейнер под `1000:1000`, `network_mode: host`, mount
`/srv` + data-том для SQLite/конфига.
## Документация: три раздела
- `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) | — |
+4 -2
View File
@@ -31,8 +31,10 @@ qBittorrent. Решим по опыту эксплуатации.
## Доступ к веб-UI
Сейчас предполагается доверенная локальная сеть. Если понадобится —
простая авторизация или вынос за reverse-proxy с аутентификацией.
Решено для v1: без авторизации в доверенной LAN, опц. allowlist подсетей
(`http.trusted_subnets`) — как умеет qBittorrent. На будущее, если
понадобится защита: токен/Basic в самом приложении или вынос за
reverse-proxy с аутентификацией.
## Повторный прогон распознавания
+11 -7
View File
@@ -10,15 +10,18 @@
копирует готовый бинарь), golangci-lint, lefthook. Документация (этот
этап — частично готов).
- **Ф1 — ingest + tracking (без LLM).** `Ingest()` + добавление в
qBittorrent (категория `jellybit`) + `worker`-поллинг завершения +
машина состояний. Наружу: HTTP API, список в веб-UI, `jellybit add`.
qBittorrent (источник отдаём ему, категория `jellybit`, ключ
идемпотентности по infohash) + `worker`-поллинг завершения (трансляция
`path_map`) + машина состояний. Наружу: HTTP API, список в веб-UI,
`jellybit add`.
- **Ф2 — распознавание.** `go-ptn` + LLM (structured output) → план +
оценка уверенности. Без записи на диск.
- **Ф3 — раскладка + минимальный review.** Хардлинки по конвенциям
Jellyfin, субтитры, идемпотентность, **undo**. Авто при высокой
уверенности; низкая → review (htmx): подсказка + перераспознавание, из
ручного — тип, выбор кандидата базы, пометка «игнор». Полный редактор
маппинга — Ф5. См. [review-ux.md](../specs/review-ux.md).
Jellyfin (санитизация пути, never-overwrite), субтитры, идемпотентность,
**undo**. Авто только при матче в базе и чистой валидации; иначе → review
(htmx): подсказка + перераспознавание, из ручного — тип, выбор кандидата
базы, пометка «игнор». Полный редактор маппинга — Ф5. См.
[review-ux.md](../specs/review-ux.md).
- **Ф4 — метаданные.** TMDB/TVDB опционально, provider-id в именах,
валидация распознавания против числа серий.
- **Ф5 — Telegram + UX.** Бот-адаптер + парсер сообщений торрент-бота,
@@ -27,7 +30,8 @@
нотификации.
- **Ф6 — деплой.** Сборка статического бинаря здесь; доставка бинаря +
`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).
- **Источник не трогаем.** В библиотеку кладём хардлинки; qBittorrent
продолжает раздачу, место на диске не дублируется.
- **Единое ядро, тонкие транспорты.** Вся логика приёма загрузки — в
use-case `Ingest`. HTTP API, веб-UI и Telegram — обёртки над ним.
- **Источник неприкосновенен** (жёсткий инвариант). 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.
включаются конфигом; без них сервис работает на одном LLM, но
авто-раскладка без матча в базе не делается (см. recognition.md).
- **Минимум компонентов.** В духе umbar — без лишних сервисов.
## Компоненты
| Пакет | Ответственность |
| ----------- | ----------------------------------------------------- |
| `ingest` | use-case приёма загрузки, общий для всех транспортов |
| `qbt` | клиент qBittorrent WebUI API |
| `worker` | фоновый цикл: машина состояний, поллинг завершения |
| `recognize` | пред-парс имени + вызов LLM + модель уверенности |
| `llm` | провайдер LLM за интерфейсом (дискриминатор `type`) |
| `metadata` | интерфейс баз метаданных + TMDB/TVDB (опц.) |
| `layout` | конвенции Jellyfin + хардлинкер |
| `store` | SQLite: загрузки, распознавание, ссылки |
| `httpapi` | REST + веб-UI (server-rendered, htmx) |
| `tgbot` | Telegram-адаптер + парсер сообщений торрент-бота |
| `config` | загрузка TOML-конфига |
| Пакет | Ответственность |
| ----------- | -------------------------------------------------------- |
| `ingest` | use-case приёма загрузки, общий для всех транспортов |
| `qbt` | клиент qBittorrent WebUI API (сессия, добавление, опрос) |
| `worker` | владелец машины состояний; поллинг, сериализация команд |
| `recognize` | пред-парс имени + вызов LLM + модель уверенности |
| `llm` | провайдер LLM за интерфейсом (дискриминатор `type`) |
| `metadata` | интерфейс баз метаданных + TMDB/TVDB (опц.) |
| `layout` | конвенции Jellyfin, санитизация путей, хардлинкер, undo |
| `store` | SQLite: загрузки, распознавание, подсказки, ссылки |
| `httpapi` | REST + веб-UI (server-rendered, htmx) |
| `tgbot` | Telegram: приём + парсер сообщений бота + исходящие пинги |
| `config` | загрузка TOML-конфига |
## Поток и машина состояний
```
ingest → downloading → completed → recognizing ─┬─ уверенно ───────→ linking → done
└─ сомнительно → review → linking → done
любой шаг при ошибке → failed
ingest → downloading → completed → recognizing ─┬─ авто ────────────────→ linking → done
└─ review ⇄ recognizing ─→ linking → done
│ │ └─ moving/checking (ещё не готов)
│ └─ stuck (не качается дольше таймаута)
└─ failed ⇄ retry
done → undo → reverted
review → «Позже» → deferred → review
любой → «Отклонить» → cancelled
```
- **ingest** — приняли источник + контекст, поставили в qBittorrent
(категория `jellybit`), записали в БД.
- **ingest** — приняли источник + контекст, отдали в qBittorrent
(категория `jellybit`), записали в БД с ключом идемпотентности.
- **downloading / completed** — `worker` поллит qBittorrent по категории
(интервал `worker.poll_interval`, по умолчанию 5 с).
- **recognizing** — `recognize` строит план раскладки и оценку
уверенности (см. [recognition.md](recognition.md)).
- **review** — план уходит человеку (веб-UI / Telegram), ждём решения;
сценарии — в [review-ux.md](review-ux.md).
- **linking** — `layout` создаёт хардлинки в библиотеке.
- **done** — опционально дёргаем скан библиотеки Jellyfin.
(`worker.poll_interval`, 5 с). Готовность — только когда файлы на месте
(не `moving`/`checking*`), см. «Завершение в qBittorrent».
- **recognizing** — `recognize` строит план и оценку уверенности
([recognition.md](recognition.md)). Невалидный/непарсящийся ответ LLM →
review (не failed).
- **review** — план уходит человеку ([review-ux.md](review-ux.md)); цикл
`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** — форма «добавить», список загрузок, экран
подтверждения раскладки (server-rendered + htmx, без JS-сборки).
- **Telegram-бот** — переслать magnet или сообщение торрент-бота прямо в
jellybit; текст становится контекстом распознавания.
- **HTTP API + веб-UI** — форма «добавить», список, экран ревью
(server-rendered + htmx). В v1 **без авторизации** (доверенная LAN), с
опциональным allowlist подсетей (`http.trusted_subnets`). Защиту
навесим позже — [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, минимум таблиц:
SQLite. Схема покрывает приём, цикл ревью и откат:
- `download` — источник, контекст, hash торрента, категория, состояние,
тайминги.
- `recognition` — тип, название, год, сезон, provider-id, оценка
уверенности, сырой ответ LLM.
- `file_link` — соответствие исходный файл → целевой путь, вид
(видео/субтитры), статус.
- `download` `id`, тип и значение источника, контекст, `infohash`,
`idempotency_key`, состояние, `error_code`/`error_msg`, тайминги.
(infohash может появиться позже приёма — для magnet без метаданных.)
- `recognition` — попытки распознавания: `download_id`, `attempt_no`,
`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'ами; реальный
`config.toml` рендерится при деплое Ansible-шаблоном из переменных umbar
(секреты — в `vars/secrets.yml` под ansible-vault) и не коммитится.
Пример:
(секреты — `vars/secrets.yml` под ansible-vault), на диске **0600**,
владелец `1000:1000`, не коммитится. Пример:
```toml
[qbittorrent]
url = "http://127.0.0.1:8989"
url = "http://127.0.0.1:8989" # работает при network_mode: host
username = "admin"
password = ""
category = "jellybit"
# qBit отдаёт пути своего контейнера ("/downloads/…"); транслируем в хост-путь
path_map = { "/downloads" = "/srv/downloads" }
[paths]
# хост-пути (видны внутри контейнера jellybit через mount /srv)
downloads = "/srv/downloads"
movies = "/srv/media/movies"
series = "/srv/media/series"
[llm]
# type — дискриминатор реализации; пока поддерживается "openai-compat"
type = "openai-compat"
base_url = "http://127.0.0.1:1234/v1"
api_key = ""
model = "qwen2.5-32b-instruct"
type = "openai-compat"
base_url = "http://127.0.0.1:1234/v1"
api_key = ""
model = "qwen2.5-32b-instruct"
timeout = "120s"
max_retries = 3 # непарсящийся ответ после ретраев → review
[metadata.tmdb]
enabled = true
enabled = false # включается ключом; без матча авто не делаем
api_key = ""
timeout = "10s"
[metadata.tvdb]
enabled = false
api_key = ""
timeout = "10s"
[worker]
poll_interval = "5s" # как часто опрашивать qBittorrent
stuck_after = "1h" # не качается дольше → stuck
magnet_timeout = "30m" # magnet без метаданных дольше → failed
[recognition]
auto_confidence_threshold = 0.85
@@ -121,10 +169,11 @@ auto_confidence_threshold = 0.85
[telegram]
enabled = false
token = ""
allowed_user_ids = []
allowed_user_ids = [] # пусто = запрет всем (fail-closed)
[http]
listen = ":8080"
listen = ":8080"
trusted_subnets = [] # опц. allowlist подсетей; пусто = без ограничений
[log]
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
(они тоже в контейнерах на umbar). Единая среда запуска перевешивает
простоту нативного systemd.
(см. [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-тулчейн на сервере не нужен.
Сборка — дёшево и сердито: статический бинарь собирается здесь; на сервер
во временную build-папку кладутся бинарь + `Dockerfile` (он просто
копирует бинарь в минимальный образ), образ собирается прямо на сервере и
запускается. Go-тулчейн на сервере не нужен — только docker.
Параметры запуска (в umbar-compose):
- **`network_mode: host`** — чтобы `127.0.0.1:8989` достучался до
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** (этот репозиторий) — производит статический бинарь и
`Dockerfile`.
- **umbar** — оркестрация деплоя: доставка артефактов, `docker build` и
запуск через docker compose (`playbook-jellybit.yml`).
## Раскладка файлов
Хардлинки в `paths.movies` / `paths.series` по конвенциям Jellyfin.
Детали и крайние случаи — в [jellyfin-layout.md](jellyfin-layout.md).
`/srv/downloads` и `/srv/media` — одна ФС (подтверждено), поэтому
хардлинки применимы. Так как jellybit в docker (см. «Деплой»), контейнеру
монтируем общего родителя `/srv` — чтобы внутри оба каталога остались на
одной ФС и хардлинк проходил.
- **jellybit** (этот репозиторий) — статический бинарь и `Dockerfile`.
- **umbar** — оркестрация: доставка артефактов, `docker build`, запуск
через docker compose (`playbook-jellybit.yml`) с параметрами выше.
## Предполагаемая структура репозитория
@@ -174,21 +278,25 @@ internal/
migrations/ миграции SQLite
web/templates/ шаблоны веб-UI
docs/ specs / adr / drafts
config.example.toml
Dockerfile .dockerignore config.example.toml
```
## Решённые вопросы
- `/srv/downloads` и `/srv/media` — одна ФС (подтверждено); хардлинки
применимы.
- Детект завершения — поллинг qBittorrent раз в несколько секунд
(`worker.poll_interval`). Webhook — возможная оптимизация на будущее
([drafts/ideas.md](../drafts/ideas.md)).
- Секреты — в переменных umbar; `config.toml` рендерится Ansible-шаблоном
при деплое.
- Форма запуска — **docker**, образ собирается на сервере из готового
бинаря (см. «Деплой»).
- Пути/контейнеры — три namespace сведены: qBit отдаёт `/downloads`,
транслируем в хост через `path_map`; jellybit монтирует `/srv`.
- Сеть jellybit↔qBittorrent — `network_mode: host`.
- Состояние — на persistent-томе `/srv/applications/jellybit/data`.
- Детект завершения — поллинг; webhook — на будущее (drafts/ideas).
- Источник (magnet/URL/.torrent) отдаём в qBittorrent — без SSRF.
- Авто-раскладка требует подтверждённого матча в базе; иначе review.
- Веб-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-...]`) добавляется при работе с
базой — снимает неоднозначность для русских названий, которые 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`. Для каждого распознанного
файла создаётся **хардлинк** в `paths.movies` / `paths.series` с целевым
именем. Исходный файл остаётся на месте (раздача продолжается), inode
общий — диск не дублируется.
Источник берём по пути из qBittorrent (`save_path`/`content_path` +
относительное имя, после трансляции `path_map` в хост-путь). Для каждого
распознанного **файла** (не каталога) создаётся **хардлинк** в
`paths.movies`/`paths.series`; целевые каталоги — `mkdir` (0755,
`1000:1000`). Исходный файл остаётся на месте (раздача продолжается),
inode общий — диск не дублируется.
Требование: целевой и исходный каталоги — на одной ФС.
Целевое имя строится из распознанных полей и **санитизируется** (без
разделителей пути, `..`, управляющих символов); финальный путь обязан
быть строго под библиотекой. Существующую цель **не перезаписываем** (тот
же inode → готово; другой файл → коллизия → review). Инварианты и undo —
в [architecture.md](architecture.md) → «Раскладка файлов».
Требование: целевой и исходный каталоги — на одной ФС (внутри контейнера
jellybit это обеспечивает mount `/srv`).
## Крайние случаи
- **Многофайловый фильм** (части) — `... part1`, `... part2` в одной
папке фильма.
- **Сезон-пак** — все серии в один `Season xx`.
- **Многофайловый фильм** (части) — стэкинг по точному токену Jellyfin
(`… - part1`/`cd1`); точный формат уточнить при реализации.
- **Редакции** — `Имя (Год) [edition-Director's Cut]` либо отдельные
версии в папке фильма.
- **Двойная серия** в одном файле — `… SxxEyy-Eyy`.
- **Спецвыпуски** — `Season 00`.
- **Сезон-пак** — серии в один `Season xx`; смешанный пак — по per-file
сезонам.
- **Несколько аудиодорожек** — обычно внутри mkv, не наша забота.
- **Аниме с абсолютной нумерацией** — требует пересчёта в S·E, отдельная
проработка ([drafts/ideas.md](../drafts/ideas.md)).
- **Аниме с абсолютной нумерацией** — пересчёт в S·E, отдельная проработка
([drafts/ideas.md](../drafts/ideas.md)).
+75 -53
View File
@@ -2,91 +2,113 @@
## Задача
По доступным сигналам определить: это фильм или сериал; каноническое
название и год; для сериала — сезон и соответствие файлов сериям; при
включённых базах — provider-id. На выходе — план раскладки и оценка
уверенности.
По доступным сигналам определить: фильм или сериал; каноническое название
и год; для сериала — сезон(ы) и соответствие файлов сериям; при включённых
базах — провайдер и его id. На выходе — план раскладки, оценка уверенности
и решение «авто или review».
## Сигналы
- Имя торрента и структура каталогов.
- Список файлов с размерами и расширениями.
- Текстовый контекст от человека.
- Распарсенное сообщение торрент-бота (если пришло через Telegram):
название с годом, качество, переводы, magnet — см. пример в
[BRIEF.md](../../BRIEF.md).
- Список файлов с размерами и расширениями. Абсолютный путь источника
восстанавливаем как `save_path`/`content_path` из qBit (после трансляции
`path_map`) + относительное имя файла; учитываем одно- и многофайловые
торренты.
- Текстовый контекст человека (+ накопленные подсказки из review).
- Распарсенное сообщение торрент-бота (если через Telegram): название с
годом, качество, переводы — см. пример в [BRIEF.md](../../BRIEF.md).
**Все сигналы недоверенные** — имя торрента, сообщение бота и контекст
управляются извне и могут содержать инъекции. Выход LLM не отвечает за
безопасность: целевой путь всё равно санитизируется и проверяется на
выход за пределы библиотеки (см. architecture.md → «Раскладка файлов»).
## Конвейер
1. **Пред-парс** имени релиза дешёвым парсером (`go-ptn`): черновые
название/год/сезон/серия и качество. Грубо, но бесплатно.
2. **LLM** (через провайдер-абстракцию, см. «Провайдер LLM»): получает
все сигналы и пред-парс, возвращает структурированный план в нашей
схеме. Хорошо справляется с русскими релиз-именами, чего не умеет
парсер.
3. **Сверка с базой** (опц., если включена TMDB/TVDB): подтверждаем
название+год, берём официальный id и каноническое имя.
4. **Оценка уверенности** и решение: авто-раскладка или ревью.
1. **Пред-парс** имени релиза (`go-ptn`): черновые название/год/сезон/
серия и качество. Грубо, но бесплатно.
2. **LLM** (через провайдер-абстракцию, см. ниже): получает сигналы и
пред-парс, возвращает структурированный план в нашей схеме. Хорошо
берёт русские релиз-имена. Длинный список файлов усекаем/семплируем под
контекст модели.
3. **Сверка с базой** (если включена TMDB/TVDB): ищем по названию+году,
берём официальный id и каноническое имя, собираем кандидатов.
4. **Оценка уверенности** и решение: авто или review.
## Структура ответа LLM (черновик)
## Структура ответа LLM (предварительная)
```
type movie | series
title каноническое название
original_title оригинальное название (если есть)
year год
season номер сезона (для сериала)
provider_hint подсказка для поиска в базе
files[] { src, role: main|episode|subtitle|extra|sample,
season?, episode? }
confidence 0..1 — самооценка модели по полям
provider_hint строка для поиска в базе (НЕ итоговый id)
files[] { src, role: main|episode|subtitle|extra|sample|ignore,
season?, episode? } # season/episode — на файл
confidence 0..1 — самооценка модели (вспомогательный сигнал)
notes пояснения, неоднозначности
```
Сезон/серия — **на файле**: так выражаются мультисезонные паки,
спецвыпуски и смешанные раскладки; отдельного скалярного `season` нет.
`provider_hint` — только подсказка для поиска; итоговые `provider`
(`tmdb|tvdb|none`) и `provider_id` появляются после сверки с базой и
хранятся отдельно.
## Провайдер LLM
Доступ к LLM — за интерфейсом; конкретная реализация выбирается полем
`[llm].type` в конфиге (дискриминатор). Это позволяет подключать
локальные модели и сторонние (в т.ч. китайские) эндпоинты — ради экономии
и независимости от одного вендора.
Доступ к LLM — за интерфейсом; реализация выбирается полем `[llm].type`
(дискриминатор). Это позволяет подключать локальные модели и сторонние
(в т.ч. китайские) эндпоинты — ради экономии и независимости от вендора.
- Первый и пока единственный тип — **`openai-compat`**: OpenAI-совместимый
Chat Completions API (`base_url` + `api_key` + `model`). Под него
подходят локальные серверы (LM Studio, llama.cpp, Ollama) и облачные
совместимые провайдеры (DeepSeek, Qwen и др.).
- Структурированный вывод: запрашиваем JSON по нашей схеме
(`response_format` со схемой там, где поддерживается; иначе json-режим
или tool-call), **валидируем в Go** и ретраим при несоответствии —
серверы различаются по поддержке строгих схем.
Chat Completions API (`base_url` + `api_key` + `model`). Подходят
локальные серверы (LM Studio, llama.cpp, Ollama) и облачные совместимые
провайдеры (DeepSeek, Qwen и др.).
- **Структурированный вывод надёжно:** просим JSON по схеме
(`response_format` со схемой где поддерживается; иначе json-режим или
tool-call); на приёме срезаем ```-ограждения и извлекаем JSON,
**валидируем в Go**, ретраим со схемой-в-промпте до `llm.max_retries`;
если так и не распарсилось — уходим в **review** (не в `failed`) с
причиной «ответ LLM не разобран». Серверы заметно различаются по
поддержке строгих схем, особенно мелкие локальные модели.
- Новые типы (напр. нативный `anthropic`) добавляются, не трогая
`recognize`.
## Модель уверенности
Авто-раскладка только если выполнено всё:
Авто-раскладка только если выполнено **всё**:
1. **Самооценка LLM** ≥ порога (`recognition.auto_confidence_threshold`).
2. **Совпадение с базой** (если включена) — единственный сильный матч по
названию+году.
3. **Структурная валидация** проходит без предупреждений:
- фильм: ровно один основной видеофайл (семплы/экстра отброшены);
- сериал: число серий бьётся с базой (если есть), нумерация S·E
консистентна, без пропусков и дублей.
1. **Подтверждённый матч в базе** — единственный сильный результат
TMDB/TVDB по названию+году, давший `provider_id`. **Нет матча (или
база выключена) → всегда review.** Это и закрывает основной кейс
(рус/аниме часто отсутствуют в базах), и снимает риск «LLM придумал».
2. **Структурная валидация** без предупреждений:
- фильм: ровно один основной видеофайл (семплы/экстра/ignore отброшены);
- сериал: число серий бьётся с базой, нумерация S·E консистентна, без
пропусков, дублей и неоднозначных спецвыпусков.
3. **Согласованность сигналов** — пред-парс (`go-ptn`) и LLM не
противоречат по типу/названию/году.
Иначе план уходит в **review** (сценарии — [review-ux.md](review-ux.md)).
На экране подтверждения всегда видно, *почему* не авто — это страховка на
дорогих файлах.
Самооценку LLM (`confidence`) учитываем как вспомогательный сигнал, но
**не как единственный гейт**: она плохо откалибрована и поддаётся
инъекции. Решают матч в базе и валидация.
Иначе — **review** ([review-ux.md](review-ux.md)) с явной причиной.
## Что делаем с краёв
- Семплы и «экстра» отбрасываем (эвристики по размеру/имени + LLM).
- Внешние субтитры (`.srt`, `.ass`) привязываем к видео и именуем по
Jellyfin (`*.ru.srt`).
- Сезон-паки разбираем по сериям; аниме с абсолютной нумерацией —
отдельный крайний случай, см. [drafts/ideas.md](../drafts/ideas.md).
- Семплы/«экстра»/мусор → роль `ignore` (эвристики размер/имя + LLM).
- Внешние субтитры (`.srt`, `.ass`, пары VobSub `.idx`+`.sub`) привязываем
к видео и именуем по Jellyfin (`*.ru.srt`).
- Сезон-паки разбираем по сериям; смешанные паки, спецвыпуски (`Season
00`), двойные серии (`SxxEyy-Eyy`) — через per-file season/episode;
любая неоднозначность → review.
- Аниме с абсолютной нумерацией — отдельный крайний случай, см.
[drafts/ideas.md](../drafts/ideas.md).
## На будущее
`go-ptn` слабее питоновского `guessit`. Если точности пред-парса не
хватит — завернуть `guessit` лёгким сервисом-спутником (один файл рядом
с бинарём). См. [drafts/ideas.md](../drafts/ideas.md).
хватит — завернуть `guessit` лёгким сервисом-спутником (один файл рядом с
бинарём). См. [drafts/ideas.md](../drafts/ideas.md).
+12 -2
View File
@@ -89,8 +89,14 @@ Fargo.S02.2015.WEB-DL.1080p.rus.eng 🟡 review
## Разделение труда
Telegram = одобрить / подсказать / выбрать кандидата / эскалировать в
веб. Веб = точные правки. Состояние ревью одно (в SQLite) — действовать
можно из любого транспорта, последнее слово побеждает.
веб. Веб = точные правки. Состояние ревью одно (в SQLite); команды из
любого транспорта сериализует `worker` под per-download блокировкой —
гонки двух транспортов нет, применяется последняя валидная команда.
**Доступ.** Telegram — по `telegram.allowed_user_ids` (пусто = запрет
всем). Веб-UI в v1 без авторизации (доверенная LAN), поэтому deep-link из
бота ведёт на открытую страницу — приемлемо по решению; защиту навесим
позже.
## Крайние сценарии
@@ -113,6 +119,10 @@ Telegram = одобрить / подсказать / выбрать кандид
- После «Применить» показываем, что создано. **Undo** — убрать созданные
хардлинки одной кнопкой (источник цел); страховка от ошибочного
подтверждения.
- **«Позже»** паркует загрузку в `deferred` (вернётся в review по
действию), **«Отклонить»** → `cancelled` (раскладку не делаем), **undo**
после применения → `reverted` (удаляет только ссылки своего батча, под
`media`). Полная карта состояний — в [architecture.md](architecture.md).
## Объём по версиям