diff --git a/CLAUDE.md b/CLAUDE.md index 7139301..618f1b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,8 +34,9 @@ перезаписываем. - **Выход LLM недоверенный** — безопасность на валидации пути, не на промпте. Авто-раскладка только при подтверждённом матче в базе. -- **Запуск:** контейнер под `1000:1000`, `network_mode: host`, mount - `/srv` + data-том для SQLite/конфига. +- **Запуск:** контейнер под `1000:1000`, в общей docker-сети (адресация + по именам), mount `/srv/media` (единая песочница) + data-том для + SQLite/конфига. ## Документация: три раздела diff --git a/docs/adr/ADR-2026-06-13-docker-deploy.md b/docs/adr/ADR-2026-06-13-docker-deploy.md index 5e7ef38..e692a81 100644 --- a/docs/adr/ADR-2026-06-13-docker-deploy.md +++ b/docs/adr/ADR-2026-06-13-docker-deploy.md @@ -26,7 +26,7 @@ jellybit запускаем в **docker** — в одной среде с qBitto сервере** из доставленного бинаря и `Dockerfile` (копирует бинарь в `distroless/static`). Go-тулчейн и реестр на сервере не нужны. `Dockerfile` (упаковка) живёт в jellybit; оркестрация (доставка, build, compose с -`network_mode: host`, `user 1000:1000`, mount `/srv` и data-тома) — в +общей docker-сетью, `user 1000:1000`, mount `/srv/media` и data-тома) — в umbar. ## Последствия diff --git a/docs/adr/ADR-2026-06-13-hardlinks.md b/docs/adr/ADR-2026-06-13-hardlinks.md index 5473928..57cbbea 100644 --- a/docs/adr/ADR-2026-06-13-hardlinks.md +++ b/docs/adr/ADR-2026-06-13-hardlinks.md @@ -7,8 +7,8 @@ jellybit раскладывает скачанные qBittorrent'ом файлы в библиотеку Jellyfin. Два требования тянут в разные стороны: раздача должна продолжаться (источник неприкосновенен), а место на диске — не -дублироваться. qBittorrent пишет в `/srv/downloads`, Jellyfin читает -`/srv/media` — обе ветки на одной ФС. +дублироваться. qBittorrent пишет в `/srv/media/downloads`, Jellyfin читает +`/srv/media/{movies,series}` — всё под единой песочницей `/srv/media`. ## Рассмотренные варианты @@ -36,7 +36,7 @@ Jellyfin. Два требования тянут в разные стороны: - `+` Ноль дублирования, мгновенно, раздача цела. - `+` Простая и безопасная модель операций: только add-link и remove-own-link. -- `-` Требуется одна ФС — внутри docker обеспечивается монтированием - общего родителя `/srv` (иначе `link(2)` даёт `EXDEV`). +- `-` Требуется один mount — внутри docker обеспечивается монтированием + единой песочницы `/srv/media` (иначе `link(2)` даёт `EXDEV`). - `-` Каталоги хардлинковать нельзя — раскладка пофайловая, целевые папки создаём сами (0755, владелец 1000:1000). diff --git a/docs/drafts/roadmap.md b/docs/drafts/roadmap.md index 9a7ecf7..1e54f34 100644 --- a/docs/drafts/roadmap.md +++ b/docs/drafts/roadmap.md @@ -11,9 +11,9 @@ этап — частично готов). - **Ф1 — ingest + tracking (без LLM).** `Ingest()` + добавление в qBittorrent (источник отдаём ему, категория `jellybit`, ключ - идемпотентности по infohash) + `worker`-поллинг завершения (трансляция - `path_map`) + машина состояний. Наружу: HTTP API, список в веб-UI, - `jellybit add`. + идемпотентности по infohash) + `worker`-поллинг завершения + (`savepath=/srv/media/downloads`, путь из API) + машина состояний. Наружу: + HTTP API, список в веб-UI, `jellybit add`. - **Ф2 — распознавание.** `go-ptn` + LLM (structured output) → план + оценка уверенности. Без записи на диск. - **Ф3 — раскладка + минимальный review.** Хардлинки по конвенциям @@ -22,16 +22,18 @@ (htmx): подсказка + перераспознавание, из ручного — тип, выбор кандидата базы, пометка «игнор». Полный редактор маппинга — Ф5. См. [review-ux.md](../specs/review-ux.md). -- **Ф4 — метаданные.** TMDB/TVDB опционально, provider-id в именах, - валидация распознавания против числа серий. +- **Ф4 — метаданные.** TMDB/TVDB опционально (с HTTP-прокси на клиента), + provider-id в именах, валидация распознавания против числа серий. - **Ф5 — Telegram + UX.** Бот-адаптер + парсер сообщений торрент-бота, подтверждение в боте (карточка + кнопки + reply-подсказка, эскалация в веб), полный редактор маппинга «файл → серия», триггер скана Jellyfin, нотификации. - **Ф6 — деплой.** Сборка статического бинаря здесь; доставка бинаря + `Dockerfile` на сервер, `docker build` и запуск на месте; оркестрация — - `playbook-jellybit.yml` в umbar: `network_mode: host`, `user 1000:1000`, - mount `/srv` + data-том `/srv/applications/jellybit/data`, healthcheck. + `playbook-jellybit.yml` в umbar: общая docker-сеть, `user 1000:1000`, + mount `/srv/media` + data-том `/srv/applications/jellybit/data`, + healthcheck. Сопутствующие правки qBit (том `/srv/media`, savepath/temp + под `/srv/media`, `WebUI\ServerDomains=*`). ## Заметки по порядку diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index a7bb6bb..225c0f1 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -114,8 +114,17 @@ SQLite. Схема покрывает приём, цикл ревью и отк - `file_link` — `download_id`, `apply_batch_id`, исходный → целевой путь, вид (видео/субтитры/…), статус, время. Батч нужен для точечного undo. -Дубликат `infohash` при приёме — повторно не добавляем, ведём к -существующей загрузке (идемпотентность). +### Идентификация торрента и повторное добавление + +Идентификатор торрента — **infohash** (v1 SHA-1 / v2 SHA-256): берём из +magnet (`xt=urn:btih:`) или считаем из `.torrent`; этим же оперирует сам +qBittorrent. Идемпотентность — **только для активных задач**: повторное +добавление, пока задача в работе, присоединяется к ней. Если прежняя +задача для этого infohash уже терминальна (`done`/`cancelled`/`failed`/ +`reverted`), новое добавление заводит **новую** задачу — перекачать тот же +торрент спустя месяцы можно без проблем (покажем, что infohash уже +обрабатывался, и прежний результат). Разные раздачи одного фильма (репаки) +имеют разные infohash → разные задачи. ## Конфигурация @@ -126,36 +135,43 @@ TOML. В репозитории — `config.example.toml` с placeholder'ами; ```toml [qbittorrent] -url = "http://127.0.0.1:8989" # работает при network_mode: host +url = "http://qbit:8989" # по имени сервиса в общей docker-сети username = "admin" password = "" category = "jellybit" -# qBit отдаёт пути своего контейнера ("/downloads/…"); транслируем в хост-путь -path_map = { "/downloads" = "/srv/downloads" } +savepath = "/srv/media/downloads" # куда qBit кладёт загрузки (задаём при добавлении) +# Обычно пусто: все медиа-приложения монтируют /srv/media:/srv/media идентично, +# поэтому путь из API уже = хост-путь. path_map — фолбэк, если пути разойдутся. +path_map = {} [paths] -# хост-пути (видны внутри контейнера jellybit через mount /srv) -downloads = "/srv/downloads" +# хост-пути (видны внутри контейнера через mount /srv/media) +downloads = "/srv/media/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" +# LLM на хосте (LM Studio) из bridged-контейнера — через host.docker.internal, +# не 127.0.0.1; либо вынести LLM в контейнер общей сети. +base_url = "http://host.docker.internal:1234/v1" api_key = "" model = "qwen2.5-32b-instruct" +proxy = "" # опц. HTTP-прокси (для удалённых эндпоинтов) timeout = "120s" max_retries = 3 # непарсящийся ответ после ретраев → review [metadata.tmdb] enabled = false # включается ключом; без матча авто не делаем api_key = "" +proxy = "" # опц. HTTP-прокси для доступа к базе timeout = "10s" [metadata.tvdb] enabled = false api_key = "" +proxy = "" timeout = "10s" [worker] @@ -199,9 +215,11 @@ format = "json" - **ошибка:** `error`/`missingFiles` → `failed`. Пути файлов берём из API (`save_path`/`content_path` + относительные -имена), не из константы, и транслируем `path_map`. Предполагаем, что -отдельный «incomplete»-каталог в qBittorrent выключен (иначе путь до -завершения иной). +имена), не из константы (обычно это уже хост-путь). «Incomplete»-каталог в +qBittorrent **включён** (`/srv/media/incomplete`): пока качается — файлы +там, по завершении qBit переносит их в `/srv/media/downloads` (состояние +`moving` — дожидаемся окончания переноса и только потом берём финальный +путь). ## Раскладка файлов @@ -223,21 +241,30 @@ Jellyfin ([jellyfin-layout.md](jellyfin-layout.md)). Правила: - **Одна ФС обязательна.** `link(2)` через границы ФС даёт `EXDEV` — падаем с понятной ошибкой; по построению этого не должно случаться. -### Пути и контейнеры (три namespace) +### Пути и контейнеры — единая песочница `/srv/media` -`/srv/downloads` и `/srv/media` — одна ФС на хосте (подтверждено), но -путь существует в трёх пространствах имён: +Весь медиа-стек лежит под одним каталогом и монтируется **идентично** +(`/srv/media:/srv/media`) во все медиа-приложения: -- **контейнер qBittorrent** видит только `/downloads` (= хост - `/srv/downloads`); `/srv/media` он не монтирует — и не должен. -- **контейнер jellybit** монтирует **общего родителя `/srv`** одним - bind-mount'ом — так `downloads` и `media` гарантированно на одной ФС - внутри него, и хардлинк проходит. -- **хост** — где обе ветки реально на одном томе. +``` +/srv/media/ + incomplete/ ← qBit качает сюда + downloads/ ← готовые раздачи (источник хардлинка) + movies/ series/ ← библиотека Jellyfin (цель хардлинка) +``` -Поэтому путь из qBittorrent (`/downloads/…`) транслируется в хост-путь -(`/srv/downloads/…`) по `qbittorrent.path_map`, и уже он используется как -источник хардлинка. +Так как всё под одним mount'ом, и **хардлинк** (downloads → movies/series), +и **мгновенный move** qBit (incomplete → downloads) работают — нет границ +между точками монтирования (`EXDEV`). Путь из qBittorrent +(`save_path`/`content_path`) уже равен хост-пути, трансляция не нужна +(`path_map` — фолбэк, обычно пуст). Секреты и чужие приложения +(`/srv/applications`) в эту песочницу не попадают. + +- **qBit** — `savepath=/srv/media/downloads`, temp `/srv/media/incomplete`. +- **jellybit** — читает `downloads`, пишет в `movies`/`series`; свой + SQLite/конфиг — отдельным mount'ом `/srv/applications/jellybit/data`. +- **Jellyfin** — библиотеки указывают на `movies`/`series` (не на корень + `/srv/media`, иначе в индекс попадут downloads/incomplete). ## Деплой @@ -250,13 +277,16 @@ Jellybit работает в **docker** — в одной среде с qBittorr Параметры запуска (в umbar-compose): -- **`network_mode: host`** — чтобы `127.0.0.1:8989` достучался до - qBittorrent (он на bridge с проброшенным портом; из отдельного - bridge-контейнера `127.0.0.1` — это он сам). Заодно `:8080` слушает на - хосте. Прецедент в umbar — glances. +- **Общая docker-сеть** (external, напр. `media-net`) — jellybit, qBit и + (позже) Jellyfin в ней; адресуемся по именам (`http://qbit:8989`, + `http://jellyfin:8096`). Веб-UI jellybit публикуем на хост (`8080:8080`) + для LAN. Учесть: qBit валидирует Host-заголовок — выставить + `WebUI\ServerDomains=*` (umbar); LLM на хосте достаётся через + `host.docker.internal` (`extra_hosts: host-gateway`). - **`user: "1000:1000"`**, UMASK 022 — единый системный пользователь umbar; созданные каталоги 0755, файлы-ссылки наследуют inode источника. -- **mount `/srv`** (общий родитель) — для хардлинков (см. выше). +- **mount `/srv/media`** (единая песочница) — для хардлинков и move + (см. «Пути и контейнеры»); `/srv/applications/jellybit/data` — отдельно. - **mount данных** `/srv/applications/jellybit/data` → `/data`: SQLite (`/data/jellybit.db`) и `config.toml`. Без него редеплой стёр бы всё in-flight состояние. @@ -283,20 +313,28 @@ Dockerfile .dockerignore config.example.toml ## Решённые вопросы -- Пути/контейнеры — три namespace сведены: qBit отдаёт `/downloads`, - транслируем в хост через `path_map`; jellybit монтирует `/srv`. -- Сеть jellybit↔qBittorrent — `network_mode: host`. +- Пути/контейнеры — единая песочница `/srv/media:/srv/media` (подпапки + incomplete/downloads/movies/series) монтируется идентично во все + медиа-приложения; путь из API = хост-путь; хардлинк и move в пределах + одного mount'а. `/srv/applications` в песочницу не попадает. +- Сеть — общая docker-сеть, адресация по именам (`qbit:8989`); host-режим + не используем. qBit: `WebUI\ServerDomains=*`; LLM на хосте — через + `host.docker.internal`. +- qBit: «incomplete» включён (`/srv/media/incomplete`), завершение + проходит через `moving`; jellybit авторизуется логином/паролем + (docker-подсеть не входит в LAN-whitelist qBit). +- Внешние базы — HTTP-прокси на клиента (`proxy` в `[metadata.*]`/`[llm]`). +- Идентификатор торрента — infohash; идемпотентность только для активных + задач (повторная закачка спустя время → новая задача). - Состояние — на persistent-томе `/srv/applications/jellybit/data`. - Детект завершения — поллинг; webhook — на будущее (drafts/ideas). - Источник (magnet/URL/.torrent) отдаём в qBittorrent — без SSRF. - Авто-раскладка требует подтверждённого матча в базе; иначе review. - Веб-UI в v1 без авторизации (доверенная LAN, опц. allowlist подсетей). - Форма запуска — docker, образ собирается на сервере; контейнер под - `1000:1000`, `network_mode: host`, mount `/srv` + data-том. + `1000:1000`, в общей docker-сети, mount `/srv/media` + data-том. ## Открытые вопросы - Конфигурация Jellyfin (URL + API-ключ) и триггер скана — когда Jellyfin будет развёрнут в umbar (сейчас его там нет). -- Подтвердить, что «incomplete»-каталог qBittorrent выключен (иначе путь - файлов до завершения иной). diff --git a/docs/specs/jellyfin-layout.md b/docs/specs/jellyfin-layout.md index cf44c1f..b88fe5d 100644 --- a/docs/specs/jellyfin-layout.md +++ b/docs/specs/jellyfin-layout.md @@ -38,7 +38,7 @@ series/ ## Сопоставление источник → цель Источник берём по пути из qBittorrent (`save_path`/`content_path` + -относительное имя, после трансляции `path_map` в хост-путь). Для каждого +относительное имя; это уже хост-путь, `path_map` — фолбэк). Для каждого распознанного **файла** (не каталога) создаётся **хардлинк** в `paths.movies`/`paths.series`; целевые каталоги — `mkdir` (0755, `1000:1000`). Исходный файл остаётся на месте (раздача продолжается), @@ -50,8 +50,8 @@ inode общий — диск не дублируется. же inode → готово; другой файл → коллизия → review). Инварианты и undo — в [architecture.md](architecture.md) → «Раскладка файлов». -Требование: целевой и исходный каталоги — на одной ФС (внутри контейнера -jellybit это обеспечивает mount `/srv`). +Требование: целевой и исходный каталоги — на одной ФС/одном mount'е +(внутри контейнера это обеспечивает единая песочница `/srv/media`). ## Крайние случаи diff --git a/docs/specs/recognition.md b/docs/specs/recognition.md index 7c795d6..d56f929 100644 --- a/docs/specs/recognition.md +++ b/docs/specs/recognition.md @@ -11,9 +11,9 @@ - Имя торрента и структура каталогов. - Список файлов с размерами и расширениями. Абсолютный путь источника - восстанавливаем как `save_path`/`content_path` из qBit (после трансляции - `path_map`) + относительное имя файла; учитываем одно- и многофайловые - торренты. + восстанавливаем как `save_path`/`content_path` из qBit (= хост-путь; + `path_map` обычно тождественен) + относительное имя файла; учитываем + одно- и многофайловые торренты. - Текстовый контекст человека (+ накопленные подсказки из review). - Распарсенное сообщение торрент-бота (если через Telegram): название с годом, качество, переводы — см. пример в [BRIEF.md](../../BRIEF.md).