Синхронизация документации и кода
This commit is contained in:
+29
-141
@@ -39,54 +39,18 @@ qBittorrent, определяет содержимое (фильм или сер
|
||||
| `metadata` | интерфейс баз метаданных + TMDB/TVDB/TVMaze (опц.) |
|
||||
| `layout` | конвенции Jellyfin, санитизация путей, хардлинкер, undo |
|
||||
| `store` | SQLite: загрузки, распознавание, подсказки, ссылки |
|
||||
| `httpapi` | REST + веб-UI (server-rendered, htmx) |
|
||||
| `httpapi` | REST + веб-UI (server-rendered, POST-формы с redirect) |
|
||||
| `tgbot` | Telegram: приём + парсер сообщений бота + исходящие пинги |
|
||||
| `jellyfin` | триггер пересканирования медиатеки после раскладки (опц.) |
|
||||
| `config` | загрузка TOML-конфига |
|
||||
|
||||
## Поток и машина состояний
|
||||
|
||||
```
|
||||
ingest → downloading → completed → recognizing ──┬─ авто ────────────────→ linking → done
|
||||
│ │ │ └─ review ⇄ recognizing ─→ linking → done
|
||||
│ │ └─ moving/checking (ещё не готов)
|
||||
│ └─ stuck (не качается дольше таймаута)
|
||||
└─ failed ⇄ retry
|
||||
|
||||
done → undo → reverted
|
||||
reverted/cancelled → «Привязать заново» → recognizing (ручная перепривязка, всегда через review)
|
||||
review → «Позже» → deferred → review
|
||||
любой → «Отклонить» → cancelled
|
||||
```
|
||||
|
||||
- **ingest** — приняли источник + контекст, отдали в qBittorrent
|
||||
(категория `jellybit`), записали в БД с ключом идемпотентности.
|
||||
- **downloading / completed** — `worker` поллит qBittorrent по категории
|
||||
(`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 (опц.,
|
||||
см. «Пересканирование Jellyfin»); доступен **undo** → `reverted` (убрать
|
||||
созданные ссылки).
|
||||
- **deferred / cancelled / failed / stuck** — «Позже», «Отклонить»,
|
||||
ошибка (ретраибельна), не качается дольше таймаута.
|
||||
- **reverted / cancelled → recognizing** — «Привязать заново»: после отката
|
||||
или отклонения можно перезапустить распознавание для той же раздачи.
|
||||
Перепривязка всегда идёт через review с ручным подтверждением (авто-раскладку
|
||||
не делаем), и требует, чтобы раздача всё ещё была в qBittorrent.
|
||||
- **review → recognizing** — кроме «Уточнить» (подсказка + перераспознавание)
|
||||
есть «Распознать заново»: повторный прогон распознавания без новой подсказки,
|
||||
по уже накопленному контексту и подсказкам.
|
||||
|
||||
Все переходы и команды идут через `worker` под per-download блокировкой —
|
||||
два транспорта не гонятся за одно состояние. Состояние персистентно в
|
||||
SQLite; на старте `worker` сверяет категорию qBittorrent с БД и
|
||||
продолжает.
|
||||
Жизненный цикл загрузки (ingest → downloading → … → done/reverted),
|
||||
полный граф состояний с переходами и сопоставление состояний qBittorrent —
|
||||
в отдельной спецификации [workflow.md](workflow.md). Ключевое: переходами
|
||||
владеет `worker`, он же сериализует команды транспортов под per-download
|
||||
блокировкой, а состояние персистентно в SQLite.
|
||||
|
||||
## Транспорты
|
||||
|
||||
@@ -115,8 +79,10 @@ SQLite. Схема покрывает приём, цикл ревью и отк
|
||||
`idempotency_key`, состояние, `error_code`/`error_msg`, тайминги.
|
||||
(infohash может появиться позже приёма — для magnet без метаданных.)
|
||||
- `recognition` — попытки распознавания: `download_id`, `attempt_no`,
|
||||
`is_current`, тип, название, год, `provider` (`tmdb|tvdb|none`),
|
||||
`provider_id`, `confidence`, причины-не-авто, сырой ответ LLM.
|
||||
`is_current`, тип, название, год, `provider` (`tmdb|tvdb|tvmaze|none`),
|
||||
`provider_id`, `confidence`, причины-не-авто, сырой ответ LLM и
|
||||
структурированный `plan` (каноничный JSON `recognize.Plan` — файл →
|
||||
роль/сезон/серия для превью и применения).
|
||||
- `hint` — накопленные подсказки человека (`download_id`, текст, время).
|
||||
- `override` — запиненные ручные правки полей (перераспознавание не
|
||||
затирает).
|
||||
@@ -139,82 +105,19 @@ qBittorrent. Идемпотентность — **только для актив
|
||||
|
||||
## Конфигурация
|
||||
|
||||
TOML. В репозитории — `config.example.toml` с placeholder'ами; реальный
|
||||
`config.toml` рендерится при деплое Ansible-шаблоном из переменных umbar
|
||||
(секреты — `vars/secrets.yml` под ansible-vault), на диске **0600**,
|
||||
владелец `1000:1000`, не коммитится. Пример:
|
||||
TOML. Полный список параметров с комментариями — в
|
||||
[`config.example.toml`](../../config.example.toml) (источник истины, не
|
||||
дублируем его здесь). Реальный `config.toml` рендерится при деплое
|
||||
Ansible-шаблоном из переменных umbar (секреты — `vars/secrets.yml` под
|
||||
ansible-vault), на диске **0600**, владелец `1000:1000`, не коммитится.
|
||||
|
||||
```toml
|
||||
[qbittorrent]
|
||||
url = "http://qbit:8989" # по имени сервиса в общей docker-сети
|
||||
username = "admin"
|
||||
password = ""
|
||||
category = "jellybit"
|
||||
savepath = "/srv/media/downloads" # куда qBit кладёт загрузки (задаём при добавлении)
|
||||
# Обычно пусто: все медиа-приложения монтируют /srv/media:/srv/media идентично,
|
||||
# поэтому путь из API уже = хост-путь. path_map — фолбэк, если пути разойдутся:
|
||||
# самый длинный совпавший префикс save_path (ключ) меняется на хост-префикс
|
||||
# (значение), совпадение — по границам сегментов. Напр. {"/data" = "/srv/media"}.
|
||||
path_map = {}
|
||||
|
||||
[paths]
|
||||
# хост-пути (видны внутри контейнера через mount /srv/media)
|
||||
downloads = "/srv/media/downloads"
|
||||
movies = "/srv/media/movies"
|
||||
series = "/srv/media/series"
|
||||
|
||||
[llm]
|
||||
# type — дискриминатор реализации; пока поддерживается "openai-compat"
|
||||
type = "openai-compat"
|
||||
# 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"
|
||||
|
||||
[jellyfin]
|
||||
enabled = false # включить пересканирование медиатеки после раскладки
|
||||
url = "http://jellyfin:8096" # по имени сервиса в общей docker-сети
|
||||
api_key = "" # API-ключ Jellyfin (Dashboard → API Keys)
|
||||
proxy = "" # опц. HTTP-прокси
|
||||
timeout = "10s"
|
||||
|
||||
[worker]
|
||||
poll_interval = "5s" # как часто опрашивать qBittorrent
|
||||
stuck_after = "1h" # не качается дольше → stuck
|
||||
magnet_timeout = "30m" # magnet без метаданных дольше → failed
|
||||
|
||||
[recognition]
|
||||
auto_confidence_threshold = 0.85
|
||||
|
||||
[telegram]
|
||||
enabled = false
|
||||
token = ""
|
||||
allowed_user_ids = [] # пусто = запрет всем (fail-closed)
|
||||
|
||||
[http]
|
||||
listen = ":8080"
|
||||
trusted_subnets = [] # зарезервировано, ПОКА НЕ ПРИМЕНЯЕТСЯ (деплой только в LAN)
|
||||
|
||||
[log]
|
||||
level = "info"
|
||||
format = "json"
|
||||
```
|
||||
Структура секций: `[qbittorrent]` (доступ + категория/тег для push/pull),
|
||||
`[paths]` (хост-пути песочницы), `[storage]` (путь к SQLite), `[llm]`
|
||||
(провайдер распознавания, см. [recognition.md](recognition.md)),
|
||||
`[metadata.tmdb|tvdb|tvmaze]` (опц. базы), `[jellyfin]` (опц.
|
||||
пересканирование), `[worker]` (интервал поллинга и таймауты, см.
|
||||
[workflow.md](workflow.md)), `[recognition]` (порог уверенности),
|
||||
`[telegram]`, `[http]`, `[log]`.
|
||||
|
||||
## Логирование
|
||||
|
||||
@@ -222,26 +125,6 @@ format = "json"
|
||||
Каждая загрузка проходит со сквозным идентификатором; решения
|
||||
распознавания (почему авто/ревью) и операции с файлами логируются явно.
|
||||
|
||||
## Завершение в 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` + относительные имена из
|
||||
`/torrents/files`, уже включающие корневую папку торрента), не из
|
||||
константы (обычно это уже хост-путь). «Incomplete»-каталог в
|
||||
qBittorrent **включён** (`/srv/media/incomplete`): пока качается — файлы
|
||||
там, по завершении qBit переносит их в `/srv/media/downloads` (состояние
|
||||
`moving` — дожидаемся окончания переноса и только потом берём финальный
|
||||
путь).
|
||||
|
||||
## Раскладка файлов
|
||||
|
||||
`layout` создаёт хардлинки в `paths.movies`/`paths.series` по конвенциям
|
||||
@@ -259,8 +142,13 @@ Jellyfin ([jellyfin-layout.md](jellyfin-layout.md)). Правила:
|
||||
доводит начатое (идемпотентно) либо откатывается.
|
||||
- **Undo** удаляет только ссылки своего `apply_batch_id` и только если
|
||||
путь под `paths.movies`/`series` — источник недосягаем.
|
||||
- **Одна ФС обязательна.** `link(2)` через границы ФС даёт `EXDEV` —
|
||||
падаем с понятной ошибкой; по построению этого не должно случаться.
|
||||
- **Хардлинк предпочтителен, но есть фолбэк.** По построению источник и
|
||||
цель — на одной ФС (единая песочница `/srv/media`), и `link(2)` проходит.
|
||||
Если ФС всё же не поддерживает жёсткие ссылки или они между разными ФС
|
||||
(`EXDEV`/`ENOTSUP`/`EOPNOTSUPP`/`EPERM`), `layout` **не падает**, а
|
||||
копирует файл (через временный файл + атомарный `rename`) и пишет в лог
|
||||
`Warn` (статус ссылки — `copied`): задача доходит до конца ценой
|
||||
дублирования места. Источник при этом всё равно не трогаем.
|
||||
|
||||
### Пути и контейнеры — единая песочница `/srv/media`
|
||||
|
||||
|
||||
Reference in New Issue
Block a user