Переделал структуру медиа директорий

This commit is contained in:
2026-06-14 10:01:01 +03:00
parent 34bd2a5b5f
commit a48f39d7f0
7 changed files with 95 additions and 54 deletions
+3 -2
View File
@@ -34,8 +34,9 @@
перезаписываем. перезаписываем.
- **Выход LLM недоверенный** — безопасность на валидации пути, не на - **Выход LLM недоверенный** — безопасность на валидации пути, не на
промпте. Авто-раскладка только при подтверждённом матче в базе. промпте. Авто-раскладка только при подтверждённом матче в базе.
- **Запуск:** контейнер под `1000:1000`, `network_mode: host`, mount - **Запуск:** контейнер под `1000:1000`, в общей docker-сети (адресация
`/srv` + data-том для SQLite/конфига. по именам), mount `/srv/media` (единая песочница) + data-том для
SQLite/конфига.
## Документация: три раздела ## Документация: три раздела
+1 -1
View File
@@ -26,7 +26,7 @@ jellybit запускаем в **docker** — в одной среде с qBitto
сервере** из доставленного бинаря и `Dockerfile` (копирует бинарь в сервере** из доставленного бинаря и `Dockerfile` (копирует бинарь в
`distroless/static`). Go-тулчейн и реестр на сервере не нужны. `Dockerfile` `distroless/static`). Go-тулчейн и реестр на сервере не нужны. `Dockerfile`
(упаковка) живёт в jellybit; оркестрация (доставка, build, compose с (упаковка) живёт в jellybit; оркестрация (доставка, build, compose с
`network_mode: host`, `user 1000:1000`, mount `/srv` и data-тома) — в общей docker-сетью, `user 1000:1000`, mount `/srv/media` и data-тома) — в
umbar. umbar.
## Последствия ## Последствия
+4 -4
View File
@@ -7,8 +7,8 @@
jellybit раскладывает скачанные qBittorrent'ом файлы в библиотеку jellybit раскладывает скачанные qBittorrent'ом файлы в библиотеку
Jellyfin. Два требования тянут в разные стороны: раздача должна Jellyfin. Два требования тянут в разные стороны: раздача должна
продолжаться (источник неприкосновенен), а место на диске — не продолжаться (источник неприкосновенен), а место на диске — не
дублироваться. qBittorrent пишет в `/srv/downloads`, Jellyfin читает дублироваться. qBittorrent пишет в `/srv/media/downloads`, Jellyfin читает
`/srv/media` — обе ветки на одной ФС. `/srv/media/{movies,series}` — всё под единой песочницей `/srv/media`.
## Рассмотренные варианты ## Рассмотренные варианты
@@ -36,7 +36,7 @@ Jellyfin. Два требования тянут в разные стороны:
- `+` Ноль дублирования, мгновенно, раздача цела. - `+` Ноль дублирования, мгновенно, раздача цела.
- `+` Простая и безопасная модель операций: только add-link и - `+` Простая и безопасная модель операций: только add-link и
remove-own-link. remove-own-link.
- `-` Требуется одна ФС — внутри docker обеспечивается монтированием - `-` Требуется один mount — внутри docker обеспечивается монтированием
общего родителя `/srv` (иначе `link(2)` даёт `EXDEV`). единой песочницы `/srv/media` (иначе `link(2)` даёт `EXDEV`).
- `-` Каталоги хардлинковать нельзя — раскладка пофайловая, целевые папки - `-` Каталоги хардлинковать нельзя — раскладка пофайловая, целевые папки
создаём сами (0755, владелец 1000:1000). создаём сами (0755, владелец 1000:1000).
+9 -7
View File
@@ -11,9 +11,9 @@
этап — частично готов). этап — частично готов).
- **Ф1 — ingest + tracking (без LLM).** `Ingest()` + добавление в - **Ф1 — ingest + tracking (без LLM).** `Ingest()` + добавление в
qBittorrent (источник отдаём ему, категория `jellybit`, ключ qBittorrent (источник отдаём ему, категория `jellybit`, ключ
идемпотентности по infohash) + `worker`-поллинг завершения (трансляция идемпотентности по infohash) + `worker`-поллинг завершения
`path_map`) + машина состояний. Наружу: HTTP API, список в веб-UI, (`savepath=/srv/media/downloads`, путь из API) + машина состояний. Наружу:
`jellybit add`. HTTP API, список в веб-UI, `jellybit add`.
- **Ф2 — распознавание.** `go-ptn` + LLM (structured output) → план + - **Ф2 — распознавание.** `go-ptn` + LLM (structured output) → план +
оценка уверенности. Без записи на диск. оценка уверенности. Без записи на диск.
- **Ф3 — раскладка + минимальный review.** Хардлинки по конвенциям - **Ф3 — раскладка + минимальный review.** Хардлинки по конвенциям
@@ -22,16 +22,18 @@
(htmx): подсказка + перераспознавание, из ручного — тип, выбор кандидата (htmx): подсказка + перераспознавание, из ручного — тип, выбор кандидата
базы, пометка «игнор». Полный редактор маппинга — Ф5. См. базы, пометка «игнор». Полный редактор маппинга — Ф5. См.
[review-ux.md](../specs/review-ux.md). [review-ux.md](../specs/review-ux.md).
- **Ф4 — метаданные.** TMDB/TVDB опционально, provider-id в именах, - **Ф4 — метаданные.** TMDB/TVDB опционально (с HTTP-прокси на клиента),
валидация распознавания против числа серий. provider-id в именах, валидация распознавания против числа серий.
- **Ф5 — Telegram + UX.** Бот-адаптер + парсер сообщений торрент-бота, - **Ф5 — Telegram + UX.** Бот-адаптер + парсер сообщений торрент-бота,
подтверждение в боте (карточка + кнопки + reply-подсказка, эскалация в подтверждение в боте (карточка + кнопки + reply-подсказка, эскалация в
веб), полный редактор маппинга «файл → серия», триггер скана Jellyfin, веб), полный редактор маппинга «файл → серия», триггер скана Jellyfin,
нотификации. нотификации.
- **Ф6 — деплой.** Сборка статического бинаря здесь; доставка бинаря + - **Ф6 — деплой.** Сборка статического бинаря здесь; доставка бинаря +
`Dockerfile` на сервер, `docker build` и запуск на месте; оркестрация — `Dockerfile` на сервер, `docker build` и запуск на месте; оркестрация —
`playbook-jellybit.yml` в umbar: `network_mode: host`, `user 1000:1000`, `playbook-jellybit.yml` в umbar: общая docker-сеть, `user 1000:1000`,
mount `/srv` + data-том `/srv/applications/jellybit/data`, healthcheck. mount `/srv/media` + data-том `/srv/applications/jellybit/data`,
healthcheck. Сопутствующие правки qBit (том `/srv/media`, savepath/temp
под `/srv/media`, `WebUI\ServerDomains=*`).
## Заметки по порядку ## Заметки по порядку
+72 -34
View File
@@ -114,8 +114,17 @@ SQLite. Схема покрывает приём, цикл ревью и отк
- `file_link``download_id`, `apply_batch_id`, исходный → целевой путь, - `file_link``download_id`, `apply_batch_id`, исходный → целевой путь,
вид (видео/субтитры/…), статус, время. Батч нужен для точечного undo. вид (видео/субтитры/…), статус, время. Батч нужен для точечного 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 ```toml
[qbittorrent] [qbittorrent]
url = "http://127.0.0.1:8989" # работает при network_mode: host url = "http://qbit:8989" # по имени сервиса в общей docker-сети
username = "admin" username = "admin"
password = "" password = ""
category = "jellybit" category = "jellybit"
# qBit отдаёт пути своего контейнера ("/downloads/…"); транслируем в хост-путь savepath = "/srv/media/downloads" # куда qBit кладёт загрузки (задаём при добавлении)
path_map = { "/downloads" = "/srv/downloads" } # Обычно пусто: все медиа-приложения монтируют /srv/media:/srv/media идентично,
# поэтому путь из API уже = хост-путь. path_map — фолбэк, если пути разойдутся.
path_map = {}
[paths] [paths]
# хост-пути (видны внутри контейнера jellybit через mount /srv) # хост-пути (видны внутри контейнера через mount /srv/media)
downloads = "/srv/downloads" downloads = "/srv/media/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" # LLM на хосте (LM Studio) из bridged-контейнера — через host.docker.internal,
# не 127.0.0.1; либо вынести LLM в контейнер общей сети.
base_url = "http://host.docker.internal:1234/v1"
api_key = "" api_key = ""
model = "qwen2.5-32b-instruct" model = "qwen2.5-32b-instruct"
proxy = "" # опц. HTTP-прокси (для удалённых эндпоинтов)
timeout = "120s" timeout = "120s"
max_retries = 3 # непарсящийся ответ после ретраев → review max_retries = 3 # непарсящийся ответ после ретраев → review
[metadata.tmdb] [metadata.tmdb]
enabled = false # включается ключом; без матча авто не делаем enabled = false # включается ключом; без матча авто не делаем
api_key = "" api_key = ""
proxy = "" # опц. HTTP-прокси для доступа к базе
timeout = "10s" timeout = "10s"
[metadata.tvdb] [metadata.tvdb]
enabled = false enabled = false
api_key = "" api_key = ""
proxy = ""
timeout = "10s" timeout = "10s"
[worker] [worker]
@@ -199,9 +215,11 @@ format = "json"
- **ошибка:** `error`/`missingFiles``failed`. - **ошибка:** `error`/`missingFiles``failed`.
Пути файлов берём из API (`save_path`/`content_path` + относительные Пути файлов берём из API (`save_path`/`content_path` + относительные
имена), не из константы, и транслируем `path_map`. Предполагаем, что имена), не из константы (обычно это уже хост-путь). «Incomplete»-каталог в
отдельный «incomplete»-каталог в qBittorrent выключен (иначе путь до qBittorrent **включён** (`/srv/media/incomplete`): пока качается — файлы
завершения иной). там, по завершении qBit переносит их в `/srv/media/downloads` (состояние
`moving` — дожидаемся окончания переноса и только потом берём финальный
путь).
## Раскладка файлов ## Раскладка файлов
@@ -223,21 +241,30 @@ Jellyfin ([jellyfin-layout.md](jellyfin-layout.md)). Правила:
- **Одна ФС обязательна.** `link(2)` через границы ФС даёт `EXDEV` - **Одна ФС обязательна.** `link(2)` через границы ФС даёт `EXDEV`
падаем с понятной ошибкой; по построению этого не должно случаться. падаем с понятной ошибкой; по построению этого не должно случаться.
### Пути и контейнеры (три namespace) ### Пути и контейнеры — единая песочница `/srv/media`
`/srv/downloads` и `/srv/media` — одна ФС на хосте (подтверждено), но Весь медиа-стек лежит под одним каталогом и монтируется **идентично**
путь существует в трёх пространствах имён: (`/srv/media:/srv/media`) во все медиа-приложения:
- **контейнер qBittorrent** видит только `/downloads` (= хост ```
`/srv/downloads`); `/srv/media` он не монтирует — и не должен. /srv/media/
- **контейнер jellybit** монтирует **общего родителя `/srv`** одним incomplete/ ← qBit качает сюда
bind-mount'ом — так `downloads` и `media` гарантированно на одной ФС downloads/ ← готовые раздачи (источник хардлинка)
внутри него, и хардлинк проходит. movies/ series/ ← библиотека Jellyfin (цель хардлинка)
- **хост** — где обе ветки реально на одном томе. ```
Поэтому путь из qBittorrent (`/downloads/…`) транслируется в хост-путь Так как всё под одним mount'ом, и **хардлинк** (downloads → movies/series),
(`/srv/downloads/…`) по `qbittorrent.path_map`, и уже он используется как и **мгновенный 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): Параметры запуска (в umbar-compose):
- **`network_mode: host`**чтобы `127.0.0.1:8989` достучался до - **Общая docker-сеть** (external, напр. `media-net`)jellybit, qBit и
qBittorrent (он на bridge с проброшенным портом; из отдельного (позже) Jellyfin в ней; адресуемся по именам (`http://qbit:8989`,
bridge-контейнера `127.0.0.1` — это он сам). Заодно `:8080` слушает на `http://jellyfin:8096`). Веб-UI jellybit публикуем на хост (`8080:8080`)
хосте. Прецедент в umbar — glances. для LAN. Учесть: qBit валидирует Host-заголовок — выставить
`WebUI\ServerDomains=*` (umbar); LLM на хосте достаётся через
`host.docker.internal` (`extra_hosts: host-gateway`).
- **`user: "1000:1000"`**, UMASK 022 — единый системный пользователь - **`user: "1000:1000"`**, UMASK 022 — единый системный пользователь
umbar; созданные каталоги 0755, файлы-ссылки наследуют inode источника. umbar; созданные каталоги 0755, файлы-ссылки наследуют inode источника.
- **mount `/srv`** (общий родитель) — для хардлинков (см. выше). - **mount `/srv/media`** (единая песочница) — для хардлинков и move
(см. «Пути и контейнеры»); `/srv/applications/jellybit/data` — отдельно.
- **mount данных** `/srv/applications/jellybit/data``/data`: SQLite - **mount данных** `/srv/applications/jellybit/data``/data`: SQLite
(`/data/jellybit.db`) и `config.toml`. Без него редеплой стёр бы всё (`/data/jellybit.db`) и `config.toml`. Без него редеплой стёр бы всё
in-flight состояние. in-flight состояние.
@@ -283,20 +313,28 @@ Dockerfile .dockerignore config.example.toml
## Решённые вопросы ## Решённые вопросы
- Пути/контейнеры — три namespace сведены: qBit отдаёт `/downloads`, - Пути/контейнеры — единая песочница `/srv/media:/srv/media` (подпапки
транслируем в хост через `path_map`; jellybit монтирует `/srv`. incomplete/downloads/movies/series) монтируется идентично во все
- Сеть jellybit↔qBittorrent — `network_mode: host`. медиа-приложения; путь из 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`. - Состояние — на persistent-томе `/srv/applications/jellybit/data`.
- Детект завершения — поллинг; webhook — на будущее (drafts/ideas). - Детект завершения — поллинг; webhook — на будущее (drafts/ideas).
- Источник (magnet/URL/.torrent) отдаём в qBittorrent — без SSRF. - Источник (magnet/URL/.torrent) отдаём в qBittorrent — без SSRF.
- Авто-раскладка требует подтверждённого матча в базе; иначе review. - Авто-раскладка требует подтверждённого матча в базе; иначе review.
- Веб-UI в v1 без авторизации (доверенная LAN, опц. allowlist подсетей). - Веб-UI в v1 без авторизации (доверенная LAN, опц. allowlist подсетей).
- Форма запуска — docker, образ собирается на сервере; контейнер под - Форма запуска — docker, образ собирается на сервере; контейнер под
`1000:1000`, `network_mode: host`, mount `/srv` + data-том. `1000:1000`, в общей docker-сети, mount `/srv/media` + data-том.
## Открытые вопросы ## Открытые вопросы
- Конфигурация Jellyfin (URL + API-ключ) и триггер скана — когда Jellyfin - Конфигурация Jellyfin (URL + API-ключ) и триггер скана — когда Jellyfin
будет развёрнут в umbar (сейчас его там нет). будет развёрнут в umbar (сейчас его там нет).
- Подтвердить, что «incomplete»-каталог qBittorrent выключен (иначе путь
файлов до завершения иной).
+3 -3
View File
@@ -38,7 +38,7 @@ series/
## Сопоставление источник → цель ## Сопоставление источник → цель
Источник берём по пути из qBittorrent (`save_path`/`content_path` + Источник берём по пути из qBittorrent (`save_path`/`content_path` +
относительное имя, после трансляции `path_map` в хост-путь). Для каждого относительное имя; это уже хост-путь, `path_map` — фолбэк). Для каждого
распознанного **файла** (не каталога) создаётся **хардлинк** в распознанного **файла** (не каталога) создаётся **хардлинк** в
`paths.movies`/`paths.series`; целевые каталоги — `mkdir` (0755, `paths.movies`/`paths.series`; целевые каталоги — `mkdir` (0755,
`1000:1000`). Исходный файл остаётся на месте (раздача продолжается), `1000:1000`). Исходный файл остаётся на месте (раздача продолжается),
@@ -50,8 +50,8 @@ inode общий — диск не дублируется.
же inode → готово; другой файл → коллизия → review). Инварианты и undo — же inode → готово; другой файл → коллизия → review). Инварианты и undo —
в [architecture.md](architecture.md) → «Раскладка файлов». в [architecture.md](architecture.md) → «Раскладка файлов».
Требование: целевой и исходный каталоги — на одной ФС (внутри контейнера Требование: целевой и исходный каталоги — на одной ФС/одном mount'е
jellybit это обеспечивает mount `/srv`). (внутри контейнера это обеспечивает единая песочница `/srv/media`).
## Крайние случаи ## Крайние случаи
+3 -3
View File
@@ -11,9 +11,9 @@
- Имя торрента и структура каталогов. - Имя торрента и структура каталогов.
- Список файлов с размерами и расширениями. Абсолютный путь источника - Список файлов с размерами и расширениями. Абсолютный путь источника
восстанавливаем как `save_path`/`content_path` из qBit (после трансляции восстанавливаем как `save_path`/`content_path` из qBit (= хост-путь;
`path_map`) + относительное имя файла; учитываем одно- и многофайловые `path_map` обычно тождественен) + относительное имя файла; учитываем
торренты. одно- и многофайловые торренты.
- Текстовый контекст человека (+ накопленные подсказки из review). - Текстовый контекст человека (+ накопленные подсказки из review).
- Распарсенное сообщение торрент-бота (если через Telegram): название с - Распарсенное сообщение торрент-бота (если через Telegram): название с
годом, качество, переводы — см. пример в [BRIEF.md](../../BRIEF.md). годом, качество, переводы — см. пример в [BRIEF.md](../../BRIEF.md).