Compare commits
4 Commits
b1f97c105a
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
1e8000429a
|
|||
|
70b3c7ae14
|
|||
|
d727966f29
|
|||
|
d149cb7481
|
@@ -0,0 +1,69 @@
|
||||
# Авто-раскладка только при подтверждённом матче в метабазе
|
||||
|
||||
- Дата: 2026-06-13
|
||||
|
||||
## Контекст
|
||||
|
||||
jellybit распознаёт содержимое релиза через LLM по **недоверенным**
|
||||
сигналам: имя торрента, текстовый контекст человека, распарсенное
|
||||
сообщение бота — всё управляется извне и может содержать инъекции. По
|
||||
результату распознавания нужно решить: разложить файлы хардлинками
|
||||
автоматически или отправить на ревью человеку. Цена ошибки авто-раскладки
|
||||
реальна — мусор в библиотеке Jellyfin под неверным названием/папкой,
|
||||
возможно поверх чужого. Хочется максимум авто, но не ценой тихих ошибок.
|
||||
|
||||
Силы и ограничения:
|
||||
|
||||
- LLM хорошо разбирает русские и релиз-имена, но галлюцинирует, а его
|
||||
самооценка (`confidence`) плохо откалибрована и тривиально поддаётся
|
||||
инъекции из тех же недоверенных сигналов.
|
||||
- Внешние базы (TMDB/TVDB/TVMaze) дают **независимый** авторитетный сигнал:
|
||||
каноническое имя + `provider_id`. Но русские релизы и аниме часто в них
|
||||
отсутствуют.
|
||||
- Безопасность раскладки уже держится на валидации пути, не на промпте
|
||||
(см. [recognition.md](../specs/recognition.md)); решение «авто vs review» —
|
||||
второй слой защиты, на уровне доверия результату.
|
||||
|
||||
## Рассмотренные варианты
|
||||
|
||||
- **Гейт по самооценке LLM (`confidence ≥ порог`).** Просто и даёт
|
||||
максимум авто. Но `confidence` не откалибрована и инъектируема —
|
||||
«уверенный» неверный ответ прошёл бы молча. Небезопасно.
|
||||
- **LLM + структурная валидация, без обязательной базы.** Ловит часть
|
||||
ошибок (число файлов у фильма, дыры/дубли в нумерации S·E), но не ловит
|
||||
«правильную структуру под неверным названием». Недостаточно как
|
||||
единственный гейт авто.
|
||||
- **Авто только при подтверждённом матче в базе + валидация +
|
||||
согласованность сигналов.** Независимый авторитет снимает риск «LLM
|
||||
придумал». Цена — рус/аниме (нет в базах) всегда идут в review, но это и
|
||||
так нужный кейс.
|
||||
|
||||
## Решение
|
||||
|
||||
Авто-раскладку делаем, только если выполнено **всё**: (1) единственный
|
||||
сильный матч в метабазе по названию+году, давший `provider_id`;
|
||||
(2) структурная валидация без предупреждений; (3) пред-парс (`go-ptn`) и
|
||||
LLM не противоречат по типу/названию/году. Нет матча или база выключена →
|
||||
**всегда review**. Самооценку LLM учитываем лишь как вспомогательный
|
||||
сигнал, не как гейт.
|
||||
|
||||
Почему так: безопасность держится на **независимой** проверке (база), а не
|
||||
на доверии к выходу LLM, построенному из недоверенных данных. Это разом
|
||||
закрывает основной кейс (рус/аниме отсутствуют в базах → человек
|
||||
подтверждает) и убирает целый класс тихих ошибок «модель уверенно
|
||||
ошиблась». Review здесь — не наказание, а штатный режим для всего, что
|
||||
база не подтвердила (петля «догадка → подсказка → перераспознавание», см.
|
||||
[review-ux.md](../specs/review-ux.md)). Полная модель уверенности — в
|
||||
[recognition.md](../specs/recognition.md).
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` Нет тихих авто-ошибок раскладки: всё неподтверждённое видит человек.
|
||||
- `+` `provider_id` из базы заодно даёт каноническое имя папки
|
||||
(`[tmdbid-…]`) — Jellyfin не путает русские названия.
|
||||
- `−` Рус/аниме и всё, чего нет в базах, всегда требует ручного
|
||||
подтверждения — авто там недоступно by design.
|
||||
- `−` Без включённых TMDB/TVDB/TVMaze авто-раскладки нет вовсе: сервис
|
||||
работает в режиме «распознал → review».
|
||||
- Делает цикл ревью критичным: если он неудобен, ручное подтверждение
|
||||
станет узким местом — поэтому review-ux вынесен в отдельную спеку.
|
||||
@@ -56,6 +56,7 @@
|
||||
|
||||
| Дата | Запись | Статус |
|
||||
| ---------- | ---------------------------------------------------------------- | ------ |
|
||||
| 2026-06-13 | [Авто-раскладка только при матче в метабазе](ADR-2026-06-13-auto-link-requires-db-match.md) | — |
|
||||
| 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) | — |
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
## Записи
|
||||
|
||||
- [architecture.md](architecture.md) — общее устройство: компоненты,
|
||||
поток, машина состояний, хранилище, конфигурация.
|
||||
транспорты, хранилище, раскладка, деплой.
|
||||
- [workflow.md](workflow.md) — жизненный цикл загрузки: машина состояний,
|
||||
переходы, сопоставление состояний qBittorrent.
|
||||
- [recognition.md](recognition.md) — распознавание контента и модель
|
||||
уверенности.
|
||||
- [review-ux.md](review-ux.md) — ревью раскладки человеком: UI/UX-сценарии
|
||||
|
||||
+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`
|
||||
|
||||
|
||||
@@ -51,8 +51,11 @@ inode общий — диск не дублируется.
|
||||
же inode → готово; другой файл → коллизия → review). Инварианты и undo —
|
||||
в [architecture.md](architecture.md) → «Раскладка файлов».
|
||||
|
||||
Требование: целевой и исходный каталоги — на одной ФС/одном mount'е
|
||||
(внутри контейнера это обеспечивает единая песочница `/srv/media`).
|
||||
Желательно: целевой и исходный каталоги — на одной ФС/одном mount'е
|
||||
(внутри контейнера это обеспечивает единая песочница `/srv/media`), тогда
|
||||
работает дешёвый хардлинк. Если хардлинк невозможен (разные ФС или ФС без
|
||||
поддержки жёстких ссылок), `layout` не падает, а копирует файл с
|
||||
предупреждением в лог — см. architecture.md → «Раскладка файлов».
|
||||
|
||||
## Крайние случаи
|
||||
|
||||
|
||||
+19
-13
@@ -5,7 +5,8 @@
|
||||
По доступным сигналам определить: фильм или сериал; каноническое название
|
||||
и год; для сериала — сезон(ы) и соответствие файлов сериям; при включённых
|
||||
базах — провайдер и его id. На выходе — план раскладки, оценка уверенности
|
||||
и решение «авто или review».
|
||||
и решение «авто или review» (как оно встраивается в машину состояний —
|
||||
[workflow.md](workflow.md), состояния `recognizing`/`linking`/`review`).
|
||||
|
||||
## Сигналы
|
||||
|
||||
@@ -33,8 +34,10 @@
|
||||
пред-парс, возвращает структурированный план в нашей схеме. Хорошо
|
||||
берёт русские релиз-имена. Длинный список файлов усекаем/семплируем под
|
||||
контекст модели.
|
||||
3. **Сверка с базой** (если включена TMDB/TVDB): ищем по названию+году,
|
||||
берём официальный id и каноническое имя, собираем кандидатов.
|
||||
3. **Сверка с базой** (если включена TMDB/TVDB/TVMaze): ищем по
|
||||
названию+году, берём официальный id и каноническое имя, собираем
|
||||
кандидатов. TVMaze — без ключа, только сериалы; внешний id
|
||||
(TVDB/IMDb) из `externals` идёт в имя папки.
|
||||
4. **Оценка уверенности** и решение: авто или review.
|
||||
|
||||
## Структура ответа LLM (предварительная)
|
||||
@@ -54,8 +57,8 @@ notes пояснения, неоднозначности
|
||||
Сезон/серия — **на файле**: так выражаются мультисезонные паки,
|
||||
спецвыпуски и смешанные раскладки; отдельного скалярного `season` нет.
|
||||
`provider_hint` — только подсказка для поиска; итоговые `provider`
|
||||
(`tmdb|tvdb|none`) и `provider_id` появляются после сверки с базой и
|
||||
хранятся отдельно.
|
||||
(`tmdb|tvdb|tvmaze|none`) и `provider_id` появляются после сверки с базой
|
||||
и хранятся отдельно.
|
||||
|
||||
## Провайдер LLM
|
||||
|
||||
@@ -67,22 +70,25 @@ notes пояснения, неоднозначности
|
||||
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 не разобран». Серверы заметно различаются по
|
||||
поддержке строгих схем, особенно мелкие локальные модели.
|
||||
- **Структурированный вывод надёжно:** просим JSON-режим
|
||||
(`response_format: {"type":"json_object"}`) — это поддерживают и мелкие
|
||||
локальные модели, в отличие от строгих JSON Schema. На приёме срезаем
|
||||
```-ограждения и извлекаем JSON, **валидируем в Go** против нашей схемы;
|
||||
при ошибке разбора ретраим, передавая модели саму ошибку и схему в
|
||||
промпте, до `llm.max_retries`. Если так и не распарсилось — уходим в
|
||||
**review** (не в `failed`) с причиной «ответ LLM не разобран».
|
||||
- Новые типы (напр. нативный `anthropic`) добавляются, не трогая
|
||||
`recognize`.
|
||||
|
||||
## Модель уверенности
|
||||
|
||||
Почему авто только при матче в базе, а не по самооценке LLM —
|
||||
[ADR-2026-06-13-auto-link-requires-db-match](../adr/ADR-2026-06-13-auto-link-requires-db-match.md).
|
||||
|
||||
Авто-раскладка — только если выполнено **всё**:
|
||||
|
||||
1. **Подтверждённый матч в базе** — единственный сильный результат
|
||||
TMDB/TVDB по названию+году, давший `provider_id`. **Нет матча (или
|
||||
TMDB/TVDB/TVMaze по названию+году, давший `provider_id`. **Нет матча (или
|
||||
база выключена) → всегда review.** Это и закрывает основной кейс
|
||||
(рус/аниме часто отсутствуют в базах), и снимает риск «LLM придумал».
|
||||
2. **Структурная валидация** без предупреждений:
|
||||
|
||||
+19
-10
@@ -2,7 +2,8 @@
|
||||
|
||||
Что происходит, когда система не уверена в распознавании и не
|
||||
раскладывает файлы автоматически. Когда именно наступает ревью — см.
|
||||
[recognition.md](recognition.md); конвенции целевых имён —
|
||||
[recognition.md](recognition.md); место состояния `review` в общем потоке —
|
||||
[workflow.md](workflow.md); конвенции целевых имён —
|
||||
[jellyfin-layout.md](jellyfin-layout.md).
|
||||
|
||||
Главный принцип: ревью — это **петля «догадка → подсказка человека →
|
||||
@@ -82,9 +83,13 @@ Fargo.S02.2015.WEB-DL.1080p.rus.eng 🟡 review
|
||||
|
||||
- **🔁 Уточнить** → бот просит подсказку ответом → перераспознаёт →
|
||||
редактирует то же сообщение новым планом. Петля коррекции прямо в чате.
|
||||
- **🔢 Выбрать в базе** → кнопки по кандидатам (название · год · id).
|
||||
- Точечное переназначение файлов в чат не помещается → **🌐 Открыть в
|
||||
вебе** (deep-link на ту же страницу).
|
||||
- Точечное переназначение файлов и выбор кандидата базы в чат не
|
||||
помещаются → **🌐 В вебе** (deep-link на ту же страницу, строится из
|
||||
`telegram.web_base_url`).
|
||||
|
||||
> Реально в боте сейчас: ✅ Применить, 📺↔🎬 Тип, 🔁 Уточнить, 🕗 Позже,
|
||||
> 🌐 В вебе, ❌ Отклонить. Кнопки «🔢 Выбрать в базе» в чате пока нет —
|
||||
> выбор кандидата и ручной ввод id делаются в вебе.
|
||||
|
||||
## Разделение труда
|
||||
|
||||
@@ -122,7 +127,7 @@ Telegram = одобрить / подсказать / выбрать кандид
|
||||
- **«Позже»** паркует загрузку в `deferred` (вернётся в review по
|
||||
действию), **«Отклонить»** → `cancelled` (раскладку не делаем), **undo**
|
||||
после применения → `reverted` (удаляет только ссылки своего батча, под
|
||||
`media`). Полная карта состояний — в [architecture.md](architecture.md).
|
||||
`media`). Полная карта состояний — в [workflow.md](workflow.md).
|
||||
- После отката или отклонения доступна **«Привязать заново»**: перезапускает
|
||||
распознавание для той же раздачи (`reverted`/`cancelled → recognizing`) и
|
||||
снова приводит в review — раскладка всегда требует ручного подтверждения,
|
||||
@@ -135,8 +140,12 @@ Telegram = одобрить / подсказать / выбрать кандид
|
||||
|
||||
## Объём по версиям
|
||||
|
||||
- **Ф3 (первая версия):** подсказка + перераспознавание; из ручного —
|
||||
переключатель типа, выбор кандидата базы, пометка файла «игнор». Undo —
|
||||
есть.
|
||||
- **Ф5:** полный редактор маппинга «файл → серия», ручной режим,
|
||||
подтверждение в Telegram с reply-подсказкой и эскалацией в веб.
|
||||
- **Ф3 (готово):** в вебе — подсказка + перераспознавание, «Распознать
|
||||
заново», переключатель типа, выбор кандидата базы / ручной ввод id /
|
||||
«без базы», пометка файла «игнор», «Применить»/«Отклонить»/«Позже»,
|
||||
Undo и «Привязать заново». В Telegram — подтверждение с reply-подсказкой
|
||||
(«Уточнить»), переключатель типа, «Позже»/«Отклонить» и эскалация в веб;
|
||||
пинги о входе в review и готовности.
|
||||
- **Ф5 (на будущее):** полный редактор маппинга «файл → серия»
|
||||
(правка S·E, «нумеровать подряд»), ручной режим при полном провале LLM,
|
||||
выбор кандидата базы и ввод id прямо в Telegram.
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# Жизненный цикл загрузки и машина состояний
|
||||
|
||||
Как загрузка проходит путь от приёма источника до разложенных файлов:
|
||||
состояния, переходы и то, что их вызывает. Кто владеет переходами и общее
|
||||
устройство — в [architecture.md](architecture.md); детали распознавания —
|
||||
в [recognition.md](recognition.md); действия человека в ревью — в
|
||||
[review-ux.md](review-ux.md).
|
||||
|
||||
## Граф состояний
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> downloading: ingest (источник отдан в qBittorrent)
|
||||
|
||||
downloading --> completed: файлы на месте
|
||||
downloading --> stuck: stalledDL дольше stuck_after
|
||||
downloading --> failed: metaDL дольше magnet_timeout / error
|
||||
|
||||
completed --> recognizing
|
||||
|
||||
recognizing --> linking: авто (матч в базе + валидация)
|
||||
recognizing --> review: нужно подтверждение / ответ LLM не разобран
|
||||
|
||||
review --> linking: Применить
|
||||
review --> recognizing: Уточнить / Распознать заново
|
||||
review --> deferred: Позже
|
||||
review --> cancelled: Отклонить
|
||||
deferred --> review: любое действие (та же поверхность)
|
||||
|
||||
linking --> done
|
||||
linking --> review: коллизия цели
|
||||
linking --> failed: ошибка ФС
|
||||
|
||||
done --> reverted: Undo
|
||||
|
||||
reverted --> recognizing: Привязать заново
|
||||
cancelled --> recognizing: Привязать заново
|
||||
|
||||
stuck --> downloading: Retry
|
||||
failed --> downloading: Retry
|
||||
|
||||
done --> [*]
|
||||
cancelled --> [*]
|
||||
reverted --> [*]
|
||||
|
||||
note right of cancelled
|
||||
«Отклонить» доступно из любого
|
||||
нетерминального состояния
|
||||
end note
|
||||
```
|
||||
|
||||
Условно-терминальные состояния — `done`, `cancelled`, `failed`,
|
||||
`reverted`: задача в них останавливается, но из `failed`/`stuck` есть
|
||||
**Retry**, а из `reverted`/`cancelled` — **Привязать заново**. `stuck`
|
||||
восстановимо ретраем.
|
||||
|
||||
## Состояния и переходы
|
||||
|
||||
- **ingest → downloading** — приняли источник + контекст, отдали в
|
||||
qBittorrent (категория `qbittorrent.category`), записали в БД с ключом
|
||||
идемпотентности. См. [architecture.md](architecture.md) → «Транспорты».
|
||||
- **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` — перераспознавание по подсказке. «Уточнить» —
|
||||
подсказка + перераспознавание; «Распознать заново» — повторный прогон
|
||||
без новой подсказки, по уже накопленному контексту и подсказкам.
|
||||
- **deferred** — «Позже» паркует задачу; принимает те же команды, что и
|
||||
`review`, и возвращается в поверхность ревью по любому действию.
|
||||
- **linking** — `layout` создаёт хардлинки; идемпотентно, батчем. Коллизия
|
||||
цели возвращает в review, ошибка ФС → failed. См.
|
||||
[architecture.md](architecture.md) → «Раскладка файлов».
|
||||
- **done** — при входе неблокирующе дёргаем пересканирование Jellyfin
|
||||
(опц., см. [architecture.md](architecture.md) → «Пересканирование
|
||||
Jellyfin»); доступен **Undo** → `reverted` (убрать созданные ссылки).
|
||||
- **stuck / failed / cancelled** — не качается дольше таймаута; ошибка
|
||||
(ретраибельна); «Отклонить».
|
||||
- **reverted / cancelled → recognizing** — «Привязать заново»: после
|
||||
отката или отклонения можно перезапустить распознавание для той же
|
||||
раздачи. Перепривязка всегда идёт через review с ручным подтверждением
|
||||
(авто-раскладку не делаем) и требует, чтобы раздача всё ещё была в
|
||||
qBittorrent.
|
||||
|
||||
Все переходы и команды идут через `worker` под per-download блокировкой —
|
||||
два транспорта не гонятся за одно состояние. Состояние персистентно в
|
||||
SQLite; `worker` периодически сверяет qBittorrent с БД и **усыновляет**
|
||||
раздачи с нашей категорией (`qbittorrent.category`) **или** тегом
|
||||
(`qbittorrent.tag`), которых ещё нет в БД, заводя для них задачу в
|
||||
состоянии `downloading`. Категория ставится на добавляемые нами раздачи
|
||||
(push, задаёт savepath); тег позволяет подхватить уже существующую
|
||||
раздачу, не трогая её категорию и файлы (pull).
|
||||
|
||||
## Завершение в qBittorrent
|
||||
|
||||
`worker` опрашивает qBittorrent и сопоставляет его состояния с нашими:
|
||||
|
||||
- **готово к раскладке:** `uploading`/`stalledUP`/`pausedUP`/`stoppedUP`/
|
||||
`queuedUP`/`forcedUP` (имена `paused*`/`stopped*` различаются между qBit
|
||||
v4 и v5 — поддержаны оба).
|
||||
- **переходное, ждём:** `moving`/`checkingUP`/`checkingResumeData`/
|
||||
`allocating` — остаёмся в `downloading`, пока qBit не закончит перенос/
|
||||
проверку (готовность не объявляем, даже если флаги «UP»).
|
||||
- **ещё качается:** `downloading`/`stalledDL`/`metaDL`/`forcedMetaDL`/
|
||||
`queuedDL`/`checkingDL`/`forcedDL`/`pausedDL`/`stoppedDL`.
|
||||
- **застряло/ошибка по таймауту:** `metaDL`/`forcedMetaDL` дольше
|
||||
`magnet_timeout` → `failed`; `stalledDL` дольше `stuck_after` → `stuck`
|
||||
(восстановимо ретраем). Возраст считаем от создания задачи.
|
||||
- **ошибка:** `error`/`missingFiles` → `failed`.
|
||||
|
||||
Пути файлов берём из API (`save_path` + относительные имена из
|
||||
`/torrents/files`, уже включающие корневую папку торрента), не из
|
||||
константы (обычно это уже хост-путь). «Incomplete»-каталог в
|
||||
qBittorrent **включён** (`/srv/media/incomplete`): пока качается — файлы
|
||||
там, по завершении qBit переносит их в `/srv/media/downloads` (состояние
|
||||
`moving` — дожидаемся окончания переноса и только потом берём финальный
|
||||
путь). Подробнее о путях и песочнице — [architecture.md](architecture.md)
|
||||
→ «Пути и контейнеры».
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
# TODO
|
||||
|
||||
Конкретные задачи на будущее, ранжированные по приоритету. Это не план
|
||||
реализации (он — в [drafts/roadmap.md](drafts/roadmap.md)) и не свалка
|
||||
идей ([drafts/ideas.md](drafts/ideas.md)): сюда попадает то, что уже решили
|
||||
сделать, но ещё не сделали. Принятое и реализованное переезжает в
|
||||
`docs/specs`/`docs/adr`.
|
||||
|
||||
Приоритет — грубая оценка «ценность / стоимость», не обязательство к
|
||||
порядку.
|
||||
|
||||
## Высокий
|
||||
|
||||
### Проблема второго сезона
|
||||
|
||||
Если первый сезон сериала уже разложен, а мы добавляем второй/третий/…,
|
||||
распознавание должно привязать новый сезон к **тому же** названию и папке,
|
||||
а не завести рядом почти одинаковую вторую папку. Ключ — стабильный
|
||||
`provider_id`: один и тот же `[tvdbid-…]` → одна папка сериала, новые
|
||||
`Season NN` доливаются внутрь. Нужно: при матче учитывать уже существующие
|
||||
в библиотеке сериалы (или прошлые распознавания с тем же провайдер-id) и
|
||||
склонять LLM/выбор кандидата к согласованности с ними.
|
||||
|
||||
Связано: [recognition.md](specs/recognition.md) (модель уверенности,
|
||||
матч в базе), [jellyfin-layout.md](specs/jellyfin-layout.md) (папка
|
||||
сериала с провайдер-id).
|
||||
|
||||
### Название из контекста при добавлении в qBittorrent
|
||||
|
||||
При создании magnet-загрузки передавать в qBittorrent человекочитаемое имя
|
||||
из контекста (если оно есть), чтобы в списке qBit не было безликих
|
||||
`rutracker-topic-6852853`. Небольшая задача с заметной отдачей в
|
||||
повседневной эксплуатации.
|
||||
|
||||
Связано: [architecture.md](specs/architecture.md) → «Транспорты», пакет
|
||||
`ingest`/`qbt`.
|
||||
|
||||
### Рассинхрон состояния с реальностью (удалённый торрент / файлы)
|
||||
|
||||
Состояние jellybit может разойтись с тем, что реально лежит на диске.
|
||||
Несколько сценариев разной остроты:
|
||||
|
||||
- **Жёсткий — удалён источник.** Раздачу удаляют (вручную или авто по
|
||||
достижении seed limit), и qBittorrent стирает скачанные файлы. Тогда
|
||||
хардлинк в библиотеке становится **последней** ссылкой на inode, и
|
||||
обычный `undo` (`unlink` цели + чистка пустых каталогов) сотрёт
|
||||
единственную копию насовсем — прямая потеря данных. Инвариант «источник
|
||||
неприкосновенен» молчаливо перестаёт держаться: источника уже нет.
|
||||
- **Мягкий — удалена цель.** Файлы убрали из библиотеки Jellyfin (вручную
|
||||
или из самого Jellyfin), а jellybit по-прежнему числит загрузку в
|
||||
`done`. Состояние врёт: ссылок уже нет, а сервис думает, что всё
|
||||
разложено.
|
||||
|
||||
Нужно продумать сверку записанного состояния (`file_link`, состояние
|
||||
загрузки) с фактом на ФС:
|
||||
|
||||
- как `worker` реагирует на исчезновение раздачи из qBittorrent
|
||||
(состояние/пометка загрузки);
|
||||
- как `undo` защищается, когда источник недоступен — например,
|
||||
отказываться удалять, если у целевого файла счётчик ссылок == 1 (нет
|
||||
второй копии) или исходный путь не существует, и явно об этом сообщать.
|
||||
Откат снимает **лишний** хардлинк, а не последнюю копию файла;
|
||||
- как ловить пропажу целевых файлов и отражать её в состоянии (напр.
|
||||
периодическая сверка или проверка при показе — «разложено, но файлов
|
||||
нет»), чтобы можно было осознанно перепривязать/переразложить.
|
||||
|
||||
Связано: [ADR-2026-06-13-hardlinks](adr/ADR-2026-06-13-hardlinks.md),
|
||||
[architecture.md](specs/architecture.md) → «Раскладка файлов» (undo,
|
||||
инвариант источника), [workflow.md](specs/workflow.md) (`done → reverted`).
|
||||
|
||||
## Средний
|
||||
|
||||
### Машина состояний на go-библиотеке
|
||||
|
||||
Сейчас FSM реализована вручную в `worker`. Выбрать подходящую go-библиотеку
|
||||
для описания воркфлоу/машины состояний и перевести переходы на неё — ради
|
||||
декларативности, проверяемости переходов и единого места правды. Кандидаты
|
||||
для оценки: `looplab/fsm`, `qmuntal/stateless` (и аналоги). Граф и переходы
|
||||
уже формализованы — переносим один в один.
|
||||
|
||||
Связано: [workflow.md](specs/workflow.md) (текущий граф состояний).
|
||||
|
||||
### Привязка уведомлений к источнику в ботах (мульти-бот)
|
||||
|
||||
Уведомления и запросы подтверждения должен получать тот, кто прислал
|
||||
загрузку: автор сообщения о новой раздаче — адресат пингов и ревью по ней.
|
||||
Транспортов-ботов может быть несколько (Telegram, в перспективе Matrix и
|
||||
др.); каждый адресует «своему» отправителю. Веб-интерфейс остаётся
|
||||
**единым для всех** и точкой правды по функциональности (боты — тонкие
|
||||
адаптеры над тем же ядром). Нужно: хранить у загрузки источник/транспорт и
|
||||
идентификатор отправителя, маршрутизировать пинги по нему.
|
||||
|
||||
Связано: [review-ux.md](specs/review-ux.md) (разделение труда транспортов,
|
||||
веб = точные правки), [architecture.md](specs/architecture.md) →
|
||||
«Транспорты».
|
||||
|
||||
### Добавление торрентов файлом/ссылкой — «единое окно»
|
||||
|
||||
Поддержать источники помимо magnet: `.torrent`-файл и URL (отдаём их в
|
||||
qBittorrent, без исходящих запросов на пользовательский URL — SSRF
|
||||
исключён). Идеал — одно поле «единого окна»: кидаем туда текст или файл, а
|
||||
сервис сам разбирает, что это (magnet / ссылка / .torrent / сообщение
|
||||
бота), и заводит загрузку.
|
||||
|
||||
Связано: [architecture.md](specs/architecture.md) → «Транспорты»
|
||||
(`source_type = magnet|torrent|url` уже в схеме), пакет `ingest` (сейчас
|
||||
поддержан только magnet).
|
||||
|
||||
## Низкий
|
||||
|
||||
### Многоступенчатая верификация привязки (тема для размышления)
|
||||
|
||||
Идея: несколько раз извлекать данные из раздачи и контекста разными
|
||||
промптами, искать в метабазах, затем сводить результаты в общий вердикт
|
||||
(голосование/консенсус) — выше точность ценой нескольких вызовов LLM и
|
||||
запросов к базам. Требует проработки: когда включать, как мерджить
|
||||
расхождения, стоимость/латентность.
|
||||
|
||||
Связано: [recognition.md](specs/recognition.md) (конвейер и модель
|
||||
уверенности).
|
||||
|
||||
### Современный Web-UI как PWA
|
||||
|
||||
Переделать веб-интерфейс в современное PWA-приложение (устанавливаемое,
|
||||
отзывчивое, удобное с телефона). Текущий server-rendered UI функционален,
|
||||
поэтому это улучшение, а не блокер; большой объём работы.
|
||||
|
||||
Связано: [review-ux.md](specs/review-ux.md) (веб = точные правки),
|
||||
пакет `httpapi`.
|
||||
Reference in New Issue
Block a user