diff --git a/CLAUDE.md b/CLAUDE.md index 8123a26..7139301 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/` — **живые** спецификации целевого состояния. Меняем по diff --git a/docs/adr/ADR-2026-06-13-docker-deploy.md b/docs/adr/ADR-2026-06-13-docker-deploy.md new file mode 100644 index 0000000..5e7ef38 --- /dev/null +++ b/docs/adr/ADR-2026-06-13-docker-deploy.md @@ -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. diff --git a/docs/adr/ADR-2026-06-13-hardlinks.md b/docs/adr/ADR-2026-06-13-hardlinks.md new file mode 100644 index 0000000..5473928 --- /dev/null +++ b/docs/adr/ADR-2026-06-13-hardlinks.md @@ -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). diff --git a/docs/adr/README.md b/docs/adr/README.md index 624b06e..0b39f76 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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) | — | diff --git a/docs/drafts/ideas.md b/docs/drafts/ideas.md index c245372..45be2ca 100644 --- a/docs/drafts/ideas.md +++ b/docs/drafts/ideas.md @@ -31,8 +31,10 @@ qBittorrent. Решим по опыту эксплуатации. ## Доступ к веб-UI -Сейчас предполагается доверенная локальная сеть. Если понадобится — -простая авторизация или вынос за reverse-proxy с аутентификацией. +Решено для v1: без авторизации в доверенной LAN, опц. allowlist подсетей +(`http.trusted_subnets`) — как умеет qBittorrent. На будущее, если +понадобится защита: токен/Basic в самом приложении или вынос за +reverse-proxy с аутентификацией. ## Повторный прогон распознавания diff --git a/docs/drafts/roadmap.md b/docs/drafts/roadmap.md index 852f150..9a7ecf7 100644 --- a/docs/drafts/roadmap.md +++ b/docs/drafts/roadmap.md @@ -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. ## Заметки по порядку diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index e5bbeb8..a7bb6bb 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -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 --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 выключен (иначе путь + файлов до завершения иной). diff --git a/docs/specs/jellyfin-layout.md b/docs/specs/jellyfin-layout.md index 9a92c88..cf44c1f 100644 --- a/docs/specs/jellyfin-layout.md +++ b/docs/specs/jellyfin-layout.md @@ -18,7 +18,9 @@ movies/ - provider-id в имени папки (`[tmdbid-...]`) добавляется при работе с базой — снимает неоднозначность для русских названий, которые Jellyfin иначе может опознать неверно. -- Внешние субтитры — `Имя..srt`, при необходимости `.forced`. +- Внешние субтитры — `Имя.[.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)). diff --git a/docs/specs/recognition.md b/docs/specs/recognition.md index 95c17fa..7c795d6 100644 --- a/docs/specs/recognition.md +++ b/docs/specs/recognition.md @@ -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). diff --git a/docs/specs/review-ux.md b/docs/specs/review-ux.md index 75e516e..d7c1f8e 100644 --- a/docs/specs/review-ux.md +++ b/docs/specs/review-ux.md @@ -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). ## Объём по версиям