Compare commits

...

7 Commits

22 changed files with 595 additions and 214 deletions
+5 -2
View File
@@ -3,6 +3,9 @@
# CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o jellybit ./cmd/jellybit # CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o jellybit ./cmd/jellybit
# distroless/static несёт CA-сертификаты (HTTPS к LLM/TMDB). Пользователь # distroless/static несёт CA-сертификаты (HTTPS к LLM/TMDB). Пользователь
# задаётся в compose (user: "1000:1000"). # задаётся в compose (user: "1000:1000").
#
# Тома (см. compose): /config (ro, рендерится плейбуком — восстановимо при
# деплое) + /data (SQLite, бекапить-и-не-терять).
FROM gcr.io/distroless/static-debian12 FROM gcr.io/distroless/static-debian12
COPY jellybit /usr/local/bin/jellybit COPY jellybit /usr/local/bin/jellybit
@@ -10,8 +13,8 @@ COPY jellybit /usr/local/bin/jellybit
EXPOSE 8080 EXPOSE 8080
# В distroless нет shell/curl — проверку делает сам бинарь (порт берёт из # В distroless нет shell/curl — проверку делает сам бинарь (порт берёт из
# /data/config.toml). compose может переопределить параметры healthcheck. # /config/config.toml — дефолтный путь). compose может переопределить параметры.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD ["/usr/local/bin/jellybit", "healthcheck"] CMD ["/usr/local/bin/jellybit", "healthcheck"]
ENTRYPOINT ["/usr/local/bin/jellybit", "--config", "/data/config.toml"] ENTRYPOINT ["/usr/local/bin/jellybit", "--config", "/config/config.toml"]
+4 -3
View File
@@ -95,6 +95,7 @@ jellybit recognize <infohash> --dry-run [--context "..."] --config ./config.toml
бинарь: `jellybit healthcheck` (GET `/healthz` по порту из конфига, exit 0/1). бинарь: `jellybit healthcheck` (GET `/healthz` по порту из конфига, exit 0/1).
Контейнер: `user 1000:1000`, порт `8080` на хост, mount `/srv/media` (единая Контейнер: `user 1000:1000`, порт `8080` на хост, mount `/srv/media` (единая
песочница для хардлинков) + data-том с `config.toml`/SQLite; к qBittorrent — песочница для хардлинков) + том `/config` (ro, `config.toml`, восстановим при
по сети Docker. Конкретная деплой-обвязка (плейбук, секреты) держится в деплое) + data-том `/data` (SQLite, бекапить); к qBittorrent — по сети Docker.
отдельном приватном репозитории и в комплект не входит. Конкретная деплой-обвязка (плейбук, секреты) держится в отдельном приватном
репозитории и в комплект не входит.
+1 -1
View File
@@ -16,7 +16,7 @@ import (
// нет shell/curl: docker зовёт сам бинарь. // нет shell/curl: docker зовёт сам бинарь.
func runHealthcheck(args []string) error { func runHealthcheck(args []string) error {
fs := flag.NewFlagSet("healthcheck", flag.ContinueOnError) fs := flag.NewFlagSet("healthcheck", flag.ContinueOnError)
configPath := fs.String("config", "/data/config.toml", "путь к config.toml") configPath := fs.String("config", "/config/config.toml", "путь к config.toml")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return err return err
} }
+1 -1
View File
@@ -23,7 +23,7 @@ import (
// Только чтение: ни записи в БД, ни хардлинков. // Только чтение: ни записи в БД, ни хардлинков.
func runRecognize(args []string) error { func runRecognize(args []string) error {
fs := flag.NewFlagSet("recognize", flag.ContinueOnError) fs := flag.NewFlagSet("recognize", flag.ContinueOnError)
configPath := fs.String("config", "/data/config.toml", "путь к config.toml") configPath := fs.String("config", "/config/config.toml", "путь к config.toml")
dryRun := fs.Bool("dry-run", true, "только показать план, без изменений (единственный режим)") dryRun := fs.Bool("dry-run", true, "только показать план, без изменений (единственный режим)")
contextStr := fs.String("context", "", "доп. текстовый контекст для распознавания") contextStr := fs.String("context", "", "доп. текстовый контекст для распознавания")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
+1 -1
View File
@@ -33,7 +33,7 @@ import (
// воркер (фоном) → HTTP-сервер; останавливается по SIGINT/SIGTERM. // воркер (фоном) → HTTP-сервер; останавливается по SIGINT/SIGTERM.
func runServe(args []string) error { func runServe(args []string) error {
fs := flag.NewFlagSet("serve", flag.ContinueOnError) fs := flag.NewFlagSet("serve", flag.ContinueOnError)
configPath := fs.String("config", "/data/config.toml", "путь к config.toml") configPath := fs.String("config", "/config/config.toml", "путь к config.toml")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return err return err
} }
@@ -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 вынесен в отдельную спеку.
+1
View File
@@ -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 | [Docker как единица деплоя](ADR-2026-06-13-docker-deploy.md) | — |
| 2026-06-13 | [Хардлинки вместо копирования и симлинков](ADR-2026-06-13-hardlinks.md) | — | | 2026-06-13 | [Хардлинки вместо копирования и симлинков](ADR-2026-06-13-hardlinks.md) | — |
| 2026-06-13 | [Go и доставка одним бинарём](ADR-2026-06-13-go-single-binary.md) | — | | 2026-06-13 | [Go и доставка одним бинарём](ADR-2026-06-13-go-single-binary.md) | — |
+3 -1
View File
@@ -15,7 +15,9 @@
## Записи ## Записи
- [architecture.md](architecture.md) — общее устройство: компоненты, - [architecture.md](architecture.md) — общее устройство: компоненты,
поток, машина состояний, хранилище, конфигурация. транспорты, хранилище, раскладка, деплой.
- [workflow.md](workflow.md) — жизненный цикл загрузки: машина состояний,
переходы, сопоставление состояний qBittorrent.
- [recognition.md](recognition.md) — распознавание контента и модель - [recognition.md](recognition.md) — распознавание контента и модель
уверенности. уверенности.
- [review-ux.md](review-ux.md) — ревью раскладки человеком: UI/UX-сценарии - [review-ux.md](review-ux.md) — ревью раскладки человеком: UI/UX-сценарии
+37 -142
View File
@@ -39,51 +39,18 @@ qBittorrent, определяет содержимое (фильм или сер
| `metadata` | интерфейс баз метаданных + TMDB/TVDB/TVMaze (опц.) | | `metadata` | интерфейс баз метаданных + TMDB/TVDB/TVMaze (опц.) |
| `layout` | конвенции Jellyfin, санитизация путей, хардлинкер, undo | | `layout` | конвенции Jellyfin, санитизация путей, хардлинкер, undo |
| `store` | SQLite: загрузки, распознавание, подсказки, ссылки | | `store` | SQLite: загрузки, распознавание, подсказки, ссылки |
| `httpapi` | REST + веб-UI (server-rendered, htmx) | | `httpapi` | REST + веб-UI (server-rendered, POST-формы с redirect) |
| `tgbot` | Telegram: приём + парсер сообщений бота + исходящие пинги | | `tgbot` | Telegram: приём + парсер сообщений бота + исходящие пинги |
| `jellyfin` | триггер пересканирования медиатеки после раскладки (опц.) | | `jellyfin` | триггер пересканирования медиатеки после раскладки (опц.) |
| `config` | загрузка TOML-конфига | | `config` | загрузка TOML-конфига |
## Поток и машина состояний ## Поток и машина состояний
``` Жизненный цикл загрузки (ingest → downloading → … → done/reverted),
ingest → downloading → completed → recognizing ──┬─ авто ────────────────→ linking → done полный граф состояний с переходами и сопоставление состояний qBittorrent —
│ │ │ └─ review ⇄ recognizing ─→ linking → done в отдельной спецификации [workflow.md](workflow.md). Ключевое: переходами
│ │ └─ moving/checking (ещё не готов) владеет `worker`, он же сериализует команды транспортов под per-download
│ └─ stuck (не качается дольше таймаута) блокировкой, а состояние персистентно в SQLite.
└─ failed ⇄ retry
done → undo → reverted
reverted → «Привязать заново» → 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 → recognizing** — «Привязать заново»: после отката можно
перезапустить распознавание для той же раздачи. Перепривязка всегда идёт
через review с ручным подтверждением (авто-раскладку не делаем), и требует,
чтобы раздача всё ещё была в qBittorrent.
Все переходы и команды идут через `worker` под per-download блокировкой —
два транспорта не гонятся за одно состояние. Состояние персистентно в
SQLite; на старте `worker` сверяет категорию qBittorrent с БД и
продолжает.
## Транспорты ## Транспорты
@@ -112,8 +79,10 @@ SQLite. Схема покрывает приём, цикл ревью и отк
`idempotency_key`, состояние, `error_code`/`error_msg`, тайминги. `idempotency_key`, состояние, `error_code`/`error_msg`, тайминги.
(infohash может появиться позже приёма — для magnet без метаданных.) (infohash может появиться позже приёма — для magnet без метаданных.)
- `recognition` — попытки распознавания: `download_id`, `attempt_no`, - `recognition` — попытки распознавания: `download_id`, `attempt_no`,
`is_current`, тип, название, год, `provider` (`tmdb|tvdb|none`), `is_current`, тип, название, год, `provider` (`tmdb|tvdb|tvmaze|none`),
`provider_id`, `confidence`, причины-не-авто, сырой ответ LLM. `provider_id`, `confidence`, причины-не-авто, сырой ответ LLM и
структурированный `plan` (каноничный JSON `recognize.Plan` — файл →
роль/сезон/серия для превью и применения).
- `hint` — накопленные подсказки человека (`download_id`, текст, время). - `hint` — накопленные подсказки человека (`download_id`, текст, время).
- `override` — запиненные ручные правки полей (перераспознавание не - `override` — запиненные ручные правки полей (перераспознавание не
затирает). затирает).
@@ -136,82 +105,19 @@ qBittorrent. Идемпотентность — **только для актив
## Конфигурация ## Конфигурация
TOML. В репозитории — `config.example.toml` с placeholder'ами; реальный TOML. Полный список параметров с комментариями — в
`config.toml` рендерится при деплое Ansible-шаблоном из переменных umbar [`config.example.toml`](../../config.example.toml) (источник истины, не
(секреты — `vars/secrets.yml` под ansible-vault), на диске **0600**, дублируем его здесь). Реальный `config.toml` рендерится при деплое
владелец `1000:1000`, не коммитится. Пример: Ansible-шаблоном из переменных umbar (секреты — `vars/secrets.yml` под
ansible-vault), на диске **0600**, владелец `1000:1000`, не коммитится.
```toml Структура секций: `[qbittorrent]` (доступ + категория/тег для push/pull),
[qbittorrent] `[paths]` (хост-пути песочницы), `[storage]` (путь к SQLite), `[llm]`
url = "http://qbit:8989" # по имени сервиса в общей docker-сети (провайдер распознавания, см. [recognition.md](recognition.md)),
username = "admin" `[metadata.tmdb|tvdb|tvmaze]` (опц. базы), `[jellyfin]` (опц.
password = "" пересканирование), `[worker]` (интервал поллинга и таймауты, см.
category = "jellybit" [workflow.md](workflow.md)), `[recognition]` (порог уверенности),
savepath = "/srv/media/downloads" # куда qBit кладёт загрузки (задаём при добавлении) `[telegram]`, `[http]`, `[log]`.
# Обычно пусто: все медиа-приложения монтируют /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"
```
## Логирование ## Логирование
@@ -219,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` по конвенциям `layout` создаёт хардлинки в `paths.movies`/`paths.series` по конвенциям
@@ -256,8 +142,13 @@ Jellyfin ([jellyfin-layout.md](jellyfin-layout.md)). Правила:
доводит начатое (идемпотентно) либо откатывается. доводит начатое (идемпотентно) либо откатывается.
- **Undo** удаляет только ссылки своего `apply_batch_id` и только если - **Undo** удаляет только ссылки своего `apply_batch_id` и только если
путь под `paths.movies`/`series` — источник недосягаем. путь под `paths.movies`/`series` — источник недосягаем.
- **Одна ФС обязательна.** `link(2)` через границы ФС даёт `EXDEV` - **Хардлинк предпочтителен, но есть фолбэк.** По построению источник и
падаем с понятной ошибкой; по построению этого не должно случаться. цель — на одной ФС (единая песочница `/srv/media`), и `link(2)` проходит.
Если ФС всё же не поддерживает жёсткие ссылки или они между разными ФС
(`EXDEV`/`ENOTSUP`/`EOPNOTSUPP`/`EPERM`), `layout` **не падает**, а
копирует файл (через временный файл + атомарный `rename`) и пишет в лог
`Warn` (статус ссылки — `copied`): задача доходит до конца ценой
дублирования места. Источник при этом всё равно не трогаем.
### Пути и контейнеры — единая песочница `/srv/media` ### Пути и контейнеры — единая песочница `/srv/media`
@@ -280,7 +171,8 @@ Jellyfin ([jellyfin-layout.md](jellyfin-layout.md)). Правила:
- **qBit** — `savepath=/srv/media/downloads`, temp `/srv/media/incomplete`. - **qBit** — `savepath=/srv/media/downloads`, temp `/srv/media/incomplete`.
- **jellybit** — читает `downloads`, пишет в `movies`/`series`; свой - **jellybit** — читает `downloads`, пишет в `movies`/`series`; свой
SQLite/конфиг — отдельным mount'ом `/srv/applications/jellybit/data`. SQLite — отдельным mount'ом `/srv/applications/jellybit/data`, конфиг —
отдельным `/srv/applications/jellybit/config`.
- **Jellyfin** — библиотеки указывают на `movies`/`series` (не на корень - **Jellyfin** — библиотеки указывают на `movies`/`series` (не на корень
`/srv/media`, иначе в индекс попадут downloads/incomplete). `/srv/media`, иначе в индекс попадут downloads/incomplete).
@@ -320,10 +212,13 @@ Jellybit работает в **docker** — в одной среде с qBittorr
- **`user: "1000:1000"`**, UMASK 022 — единый системный пользователь - **`user: "1000:1000"`**, UMASK 022 — единый системный пользователь
umbar; созданные каталоги 0755, файлы-ссылки наследуют inode источника. umbar; созданные каталоги 0755, файлы-ссылки наследуют inode источника.
- **mount `/srv/media`** (единая песочница) — для хардлинков и move - **mount `/srv/media`** (единая песочница) — для хардлинков и move
(см. «Пути и контейнеры»); `/srv/applications/jellybit/data` — отдельно. (см. «Пути и контейнеры»); каталоги jellybit — отдельно.
- **mount конфига** `/srv/applications/jellybit/config``/config` (ro):
`config.toml` (0600). Восстановим при деплое (рендерит плейбук umbar) —
бекапить не нужно.
- **mount данных** `/srv/applications/jellybit/data``/data`: SQLite - **mount данных** `/srv/applications/jellybit/data``/data`: SQLite
(`/data/jellybit.db`) и `config.toml`. Без него редеплой стёр бы всё (`/data/jellybit.db`). Бекапить-и-не-терять — без него редеплой стёр бы
in-flight состояние. всё in-flight состояние.
- **healthcheck** на `/healthz`. - **healthcheck** на `/healthz`.
Разделение ответственности: Разделение ответственности:
+5 -2
View File
@@ -51,8 +51,11 @@ inode общий — диск не дублируется.
же inode → готово; другой файл → коллизия → review). Инварианты и undo — же inode → готово; другой файл → коллизия → review). Инварианты и undo —
в [architecture.md](architecture.md) → «Раскладка файлов». в [architecture.md](architecture.md) → «Раскладка файлов».
Требование: целевой и исходный каталоги — на одной ФС/одном mount'е Желательно: целевой и исходный каталоги — на одной ФС/одном mount'е
(внутри контейнера это обеспечивает единая песочница `/srv/media`). (внутри контейнера это обеспечивает единая песочница `/srv/media`), тогда
работает дешёвый хардлинк. Если хардлинк невозможен (разные ФС или ФС без
поддержки жёстких ссылок), `layout` не падает, а копирует файл с
предупреждением в лог — см. architecture.md → «Раскладка файлов».
## Крайние случаи ## Крайние случаи
+19 -13
View File
@@ -5,7 +5,8 @@
По доступным сигналам определить: фильм или сериал; каноническое название По доступным сигналам определить: фильм или сериал; каноническое название
и год; для сериала — сезон(ы) и соответствие файлов сериям; при включённых и год; для сериала — сезон(ы) и соответствие файлов сериям; при включённых
базах — провайдер и его id. На выходе — план раскладки, оценка уверенности базах — провайдер и его id. На выходе — план раскладки, оценка уверенности
и решение «авто или review». и решение «авто или review» (как оно встраивается в машину состояний —
[workflow.md](workflow.md), состояния `recognizing`/`linking`/`review`).
## Сигналы ## Сигналы
@@ -33,8 +34,10 @@
пред-парс, возвращает структурированный план в нашей схеме. Хорошо пред-парс, возвращает структурированный план в нашей схеме. Хорошо
берёт русские релиз-имена. Длинный список файлов усекаем/семплируем под берёт русские релиз-имена. Длинный список файлов усекаем/семплируем под
контекст модели. контекст модели.
3. **Сверка с базой** (если включена TMDB/TVDB): ищем по названию+году, 3. **Сверка с базой** (если включена TMDB/TVDB/TVMaze): ищем по
берём официальный id и каноническое имя, собираем кандидатов. названию+году, берём официальный id и каноническое имя, собираем
кандидатов. TVMaze — без ключа, только сериалы; внешний id
(TVDB/IMDb) из `externals` идёт в имя папки.
4. **Оценка уверенности** и решение: авто или review. 4. **Оценка уверенности** и решение: авто или review.
## Структура ответа LLM (предварительная) ## Структура ответа LLM (предварительная)
@@ -54,8 +57,8 @@ notes пояснения, неоднозначности
Сезон/серия — **на файле**: так выражаются мультисезонные паки, Сезон/серия — **на файле**: так выражаются мультисезонные паки,
спецвыпуски и смешанные раскладки; отдельного скалярного `season` нет. спецвыпуски и смешанные раскладки; отдельного скалярного `season` нет.
`provider_hint` — только подсказка для поиска; итоговые `provider` `provider_hint` — только подсказка для поиска; итоговые `provider`
(`tmdb|tvdb|none`) и `provider_id` появляются после сверки с базой и (`tmdb|tvdb|tvmaze|none`) и `provider_id` появляются после сверки с базой
хранятся отдельно. и хранятся отдельно.
## Провайдер LLM ## Провайдер LLM
@@ -67,22 +70,25 @@ notes пояснения, неоднозначности
Chat Completions API (`base_url` + `api_key` + `model`). Подходят Chat Completions API (`base_url` + `api_key` + `model`). Подходят
локальные серверы (LM Studio, llama.cpp, Ollama) и облачные совместимые локальные серверы (LM Studio, llama.cpp, Ollama) и облачные совместимые
провайдеры (DeepSeek, Qwen и др.). провайдеры (DeepSeek, Qwen и др.).
- **Структурированный вывод надёжно:** просим JSON по схеме - **Структурированный вывод надёжно:** просим JSON-режим
(`response_format` со схемой где поддерживается; иначе json-режим или (`response_format: {"type":"json_object"}`) — это поддерживают и мелкие
tool-call); на приёме срезаем ```-ограждения и извлекаем JSON, локальные модели, в отличие от строгих JSON Schema. На приёме срезаем
**валидируем в Go**, ретраим со схемой-в-промпте до `llm.max_retries`; ```-ограждения и извлекаем JSON, **валидируем в Go** против нашей схемы;
если так и не распарсилось — уходим в **review** (не в `failed`) с при ошибке разбора ретраим, передавая модели саму ошибку и схему в
причиной «ответ LLM не разобран». Серверы заметно различаются по промпте, до `llm.max_retries`. Если так и не распарсилось — уходим в
поддержке строгих схем, особенно мелкие локальные модели. **review** (не в `failed`) с причиной «ответ LLM не разобран».
- Новые типы (напр. нативный `anthropic`) добавляются, не трогая - Новые типы (напр. нативный `anthropic`) добавляются, не трогая
`recognize`. `recognize`.
## Модель уверенности ## Модель уверенности
Почему авто только при матче в базе, а не по самооценке LLM —
[ADR-2026-06-13-auto-link-requires-db-match](../adr/ADR-2026-06-13-auto-link-requires-db-match.md).
Авто-раскладка — только если выполнено **всё**: Авто-раскладка — только если выполнено **всё**:
1. **Подтверждённый матч в базе** — единственный сильный результат 1. **Подтверждённый матч в базе** — единственный сильный результат
TMDB/TVDB по названию+году, давший `provider_id`. **Нет матча (или TMDB/TVDB/TVMaze по названию+году, давший `provider_id`. **Нет матча (или
база выключена) → всегда review.** Это и закрывает основной кейс база выключена) → всегда review.** Это и закрывает основной кейс
(рус/аниме часто отсутствуют в базах), и снимает риск «LLM придумал». (рус/аниме часто отсутствуют в базах), и снимает риск «LLM придумал».
2. **Структурная валидация** без предупреждений: 2. **Структурная валидация** без предупреждений:
+28 -14
View File
@@ -2,7 +2,8 @@
Что происходит, когда система не уверена в распознавании и не Что происходит, когда система не уверена в распознавании и не
раскладывает файлы автоматически. Когда именно наступает ревью — см. раскладывает файлы автоматически. Когда именно наступает ревью — см.
[recognition.md](recognition.md); конвенции целевых имён [recognition.md](recognition.md); место состояния `review` в общем потоке
[workflow.md](workflow.md); конвенции целевых имён —
[jellyfin-layout.md](jellyfin-layout.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,16 +127,25 @@ Telegram = одобрить / подсказать / выбрать кандид
- **«Позже»** паркует загрузку в `deferred` (вернётся в review по - **«Позже»** паркует загрузку в `deferred` (вернётся в review по
действию), **«Отклонить»** → `cancelled` (раскладку не делаем), **undo** действию), **«Отклонить»** → `cancelled` (раскладку не делаем), **undo**
после применения → `reverted` (удаляет только ссылки своего батча, под после применения → `reverted` (удаляет только ссылки своего батча, под
`media`). Полная карта состояний — в [architecture.md](architecture.md). `media`). Полная карта состояний — в [workflow.md](workflow.md).
- После отката доступна **«Привязать заново»**: перезапускает распознавание - После отката или отклонения доступна **«Привязать заново»**: перезапускает
для той же раздачи (`reverted → recognizing`) и снова приводит в review — распознавание для той же раздачи (`reverted`/`cancelled → recognizing`) и
раскладка всегда требует ручного подтверждения, авто не делаем. Нужна, снова приводит в review — раскладка всегда требует ручного подтверждения,
когда распознали неверно: откатил, перепривязал, поправил и применил. авто не делаем. Нужна, когда распознали неверно: откатил/отклонил,
перепривязал, поправил и применил.
- В самом ревью, помимо **«Уточнить»** (подсказка + перераспознавание), есть
**«Распознать заново»** — повторный прогон распознавания без новой подсказки
(контекст и прежние подсказки уже учтены). Полезно, когда модель один раз
споткнулась на разовой ошибке.
## Объём по версиям ## Объём по версиям
- **Ф3 (первая версия):** подсказка + перераспознавание; из ручного — - **Ф3 (готово):** в вебе — подсказка + перераспознавание, «Распознать
переключатель типа, выбор кандидата базы, пометка файла «игнор». Undo — заново», переключатель типа, выбор кандидата базы / ручной ввод id /
есть. «без базы», пометка файла «игнор», «Применить»/«Отклонить»/«Позже»,
- **Ф5:** полный редактор маппинга «файл → серия», ручной режим, Undo и «Привязать заново». В Telegram — подтверждение с reply-подсказкой
подтверждение в Telegram с reply-подсказкой и эскалацией в веб. («Уточнить»), переключатель типа, «Позже»/«Отклонить» и эскалация в веб;
пинги о входе в review и готовности.
- **Ф5 (на будущее):** полный редактор маппинга «файл → серия»
(правка S·E, «нумеровать подряд»), ручной режим при полном провале LLM,
выбор кандидата базы и ввод id прямо в Telegram.
+121
View File
@@ -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
View File
@@ -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`.
+22 -4
View File
@@ -13,6 +13,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -85,6 +86,7 @@ func NewRouter(d Deps) (http.Handler, error) {
r.Get("/review/{id}", s.handleReview) r.Get("/review/{id}", s.handleReview)
r.Post("/ui/downloads/{id}/apply", s.handleApply) r.Post("/ui/downloads/{id}/apply", s.handleApply)
r.Post("/ui/downloads/{id}/refine", s.handleRefine) r.Post("/ui/downloads/{id}/refine", s.handleRefine)
r.Post("/ui/downloads/{id}/rerecognize", s.handleRerecognize)
r.Post("/ui/downloads/{id}/type", s.handleSetType) r.Post("/ui/downloads/{id}/type", s.handleSetType)
r.Post("/ui/downloads/{id}/ignore", s.handleIgnore) r.Post("/ui/downloads/{id}/ignore", s.handleIgnore)
r.Post("/ui/downloads/{id}/candidate", s.handleChooseCandidate) r.Post("/ui/downloads/{id}/candidate", s.handleChooseCandidate)
@@ -127,7 +129,7 @@ type downloadView struct {
Terminal bool Terminal bool
Reviewable bool // review/deferred — есть экран ревью Reviewable bool // review/deferred — есть экран ревью
Undoable bool // done — можно откатить раскладку Undoable bool // done — можно откатить раскладку
Relinkable bool // reverted — можно перепривязать заново Relinkable bool // reverted/cancelled — можно перепривязать заново
} }
func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) { func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
@@ -306,7 +308,7 @@ func toView(d store.Download) downloadView {
Terminal: d.State.IsTerminal(), Terminal: d.State.IsTerminal(),
Reviewable: d.State == store.StateReview || d.State == store.StateDeferred, Reviewable: d.State == store.StateReview || d.State == store.StateDeferred,
Undoable: d.State == store.StateDone, Undoable: d.State == store.StateDone,
Relinkable: d.State == store.StateReverted, Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled,
} }
} }
@@ -339,7 +341,9 @@ func errJSON(err error) map[string]string {
return map[string]string{"error": err.Error()} return map[string]string{"error": err.Error()}
} }
// requestLogger пишет структурированный лог по каждому запросу. // requestLogger пишет структурированный лог по каждому запросу. Частые
// служебные запросы (healthcheck, GET-страницы веб-UI с авто-рефрешем) пишем
// на DEBUG, чтобы не зашумлять INFO; мутации и REST API остаются на INFO.
func requestLogger(logger *slog.Logger) func(http.Handler) http.Handler { func requestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -348,7 +352,7 @@ func requestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
next.ServeHTTP(ww, r) next.ServeHTTP(ww, r)
logger.Info("http request", logger.Log(r.Context(), requestLogLevel(r), "http request",
"method", r.Method, "method", r.Method,
"path", r.URL.Path, "path", r.URL.Path,
"status", ww.Status(), "status", ww.Status(),
@@ -359,3 +363,17 @@ func requestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
}) })
} }
} }
// requestLogLevel понижает уровень для частых служебных запросов: healthcheck
// и GET-страницы веб-UI (список авто-рефрешится каждые 5 с). Мутации и REST
// API (`/api/...`) остаются на INFO.
func requestLogLevel(r *http.Request) slog.Level {
switch {
case r.URL.Path == "/healthz":
return slog.LevelDebug
case r.Method == http.MethodGet && !strings.HasPrefix(r.URL.Path, "/api"):
return slog.LevelDebug
default:
return slog.LevelInfo
}
}
+19
View File
@@ -194,6 +194,7 @@ type fakeReviewer struct {
deferred []int64 deferred []int64
undone []int64 undone []int64
relinked []int64 relinked []int64
rerecognized []int64
cleared []int64 cleared []int64
} }
@@ -240,6 +241,10 @@ func (f *fakeReviewer) Relink(_ context.Context, id int64) error {
f.relinked = append(f.relinked, id) f.relinked = append(f.relinked, id)
return nil return nil
} }
func (f *fakeReviewer) Rerecognize(_ context.Context, id int64) error {
f.rerecognized = append(f.rerecognized, id)
return nil
}
func (f *fakeReviewer) ChooseCandidate(_ context.Context, id, candidateID int64) error { func (f *fakeReviewer) ChooseCandidate(_ context.Context, id, candidateID int64) error {
if f.chosen == nil { if f.chosen == nil {
f.chosen = map[int64]int64{} f.chosen = map[int64]int64{}
@@ -463,3 +468,17 @@ func TestRelink(t *testing.T) {
t.Errorf("relinked = %v, want [1]", rv.relinked) t.Errorf("relinked = %v, want [1]", rv.relinked)
} }
} }
func TestRerecognize(t *testing.T) {
rv := &fakeReviewer{data: seriesReviewData()}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
cl := noRedirectClient()
if _, err := cl.Post(srv.URL+"/ui/downloads/1/rerecognize", "", nil); err != nil {
t.Fatal(err)
}
if len(rv.rerecognized) != 1 || rv.rerecognized[0] != 1 {
t.Errorf("rerecognized = %v, want [1]", rv.rerecognized)
}
}
@@ -0,0 +1,27 @@
package httpapi
import (
"log/slog"
"net/http/httptest"
"testing"
)
func TestRequestLogLevel(t *testing.T) {
cases := []struct {
method, path string
want slog.Level
}{
{"GET", "/healthz", slog.LevelDebug}, // healthcheck — тихо
{"GET", "/", slog.LevelDebug}, // список (авто-рефреш)
{"GET", "/review/1", slog.LevelDebug}, // страница ревью
{"GET", "/api/downloads", slog.LevelInfo}, // REST API — на INFO
{"POST", "/ui/downloads/1/apply", slog.LevelInfo}, // мутация — на INFO
{"POST", "/api/downloads", slog.LevelInfo}, // приём — на INFO
}
for _, c := range cases {
r := httptest.NewRequest(c.method, c.path, nil)
if got := requestLogLevel(r); got != c.want {
t.Errorf("%s %s: level=%v, want %v", c.method, c.path, got, c.want)
}
}
}
+7
View File
@@ -21,6 +21,7 @@ type Reviewer interface {
Defer(ctx context.Context, id int64) error Defer(ctx context.Context, id int64) error
Undo(ctx context.Context, id int64) error Undo(ctx context.Context, id int64) error
Relink(ctx context.Context, id int64) error Relink(ctx context.Context, id int64) error
Rerecognize(ctx context.Context, id int64) error
ChooseCandidate(ctx context.Context, id, candidateID int64) error ChooseCandidate(ctx context.Context, id, candidateID int64) error
SetProviderID(ctx context.Context, id int64, provider, providerID string) error SetProviderID(ctx context.Context, id int64, provider, providerID string) error
ClearProvider(ctx context.Context, id int64) error ClearProvider(ctx context.Context, id int64) error
@@ -166,6 +167,12 @@ func (s *server) handleRefine(w http.ResponseWriter, r *http.Request) {
}) })
} }
func (s *server) handleRerecognize(w http.ResponseWriter, r *http.Request) {
s.reviewAction(w, r, func(ctx context.Context, id int64) error {
return s.deps.Reviewer.Rerecognize(ctx, id)
})
}
func (s *server) handleSetType(w http.ResponseWriter, r *http.Request) { func (s *server) handleSetType(w http.ResponseWriter, r *http.Request) {
s.reviewAction(w, r, func(ctx context.Context, id int64) error { s.reviewAction(w, r, func(ctx context.Context, id int64) error {
_ = r.ParseForm() _ = r.ParseForm()
+9 -7
View File
@@ -36,7 +36,7 @@ const schemaText = `Схема ответа (строгий JSON, без markdow
"provider_hint": "строка для поиска в базе (НЕ id)", "provider_hint": "строка для поиска в базе (НЕ id)",
"files": [ "files": [
{ {
"src": "путь файла РОВНО как в списке ниже", "src": "путь файла из списка ниже, БЕЗ размера в скобках в конце строки",
"role": "main" | "episode" | "subtitle" | "extra" | "sample" | "ignore", "role": "main" | "episode" | "subtitle" | "extra" | "sample" | "ignore",
"season": число или null, "season": число или null,
"episode": число или null "episode": число или null
@@ -50,7 +50,8 @@ const schemaText = `Схема ответа (строгий JSON, без markdow
- "files" покрывает каждый значимый файл; семплы/мусор помечай ролью "sample"/"ignore". - "files" покрывает каждый значимый файл; семплы/мусор помечай ролью "sample"/"ignore".
- Для сериала каждой серии — отдельный файл с role "episode" и заполненными season и episode. - Для сериала каждой серии — отдельный файл с role "episode" и заполненными season и episode.
- Для фильма ровно один основной видеофайл role "main". - Для фильма ровно один основной видеофайл role "main".
- Поле src копируй ДОСЛОВНО из списка файлов; не выдумывай и не нормализуй пути. - Поле src — это путь файла из списка, скопированный дословно, но БЕЗ размера
«(…)» в конце строки; не выдумывай и не нормализуй пути.
- Внешние субтитры — role "subtitle".` - Внешние субтитры — role "subtitle".`
const systemPrompt = `Ты распознаёшь медиа-раздачи для медиатеки Jellyfin: по имени торрента, const systemPrompt = `Ты распознаёшь медиа-раздачи для медиатеки Jellyfin: по имени торрента,
@@ -133,14 +134,15 @@ func writeFileList(b *strings.Builder, files []File, maxFiles int) {
} }
b.WriteString("Файлы (") b.WriteString("Файлы (")
b.WriteString(strconv.Itoa(n)) b.WriteString(strconv.Itoa(n))
b.WriteString(", поле src — это точные пути отсюда):\n") b.WriteString("). В src копируй ТОЛЬКО путь — текст после номера и до размера ")
b.WriteString("в скобках; размер «(…)» в конце строки в src НЕ включай:\n")
for i := 0; i < shown; i++ { for i := 0; i < shown; i++ {
b.WriteString(strconv.Itoa(i + 1)) b.WriteString(strconv.Itoa(i + 1))
b.WriteString(". [") b.WriteString(". ")
b.WriteString(humanSize(files[i].Size))
b.WriteString("] ")
b.WriteString(files[i].Path) b.WriteString(files[i].Path)
b.WriteByte('\n') b.WriteString(" (")
b.WriteString(humanSize(files[i].Size))
b.WriteString(")\n")
} }
if shown < n { if shown < n {
b.WriteString("… и ещё ") b.WriteString("… и ещё ")
+24 -8
View File
@@ -286,11 +286,11 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
return nil return nil
} }
// Relink повторно привязывает откатанную задачу (reverted): возвращает её на // Relink повторно привязывает откатанную (reverted) или отклонённую
// распознавание, и поллинг-цикл перезапустит recognize. Авто-раскладку при // (cancelled) задачу: возвращает её на распознавание, и поллинг-цикл
// этом не делаем — ручная перепривязка всегда проходит через ревью с // перезапустит recognize. Авто-раскладку при этом не делаем — ручная
// подтверждением (force_review). Источник (раздача в qBittorrent) для этого // перепривязка всегда проходит через ревью с подтверждением (force_review).
// должен быть на месте. // Источник (раздача в qBittorrent) для этого должен быть на месте.
func (w *Worker) Relink(ctx context.Context, id int64) error { func (w *Worker) Relink(ctx context.Context, id int64) error {
w.mu.Lock() w.mu.Lock()
defer w.mu.Unlock() defer w.mu.Unlock()
@@ -299,8 +299,8 @@ func (w *Worker) Relink(ctx context.Context, id int64) error {
if err != nil { if err != nil {
return fmt.Errorf("relink: %w", err) return fmt.Errorf("relink: %w", err)
} }
if d.State != store.StateReverted { if d.State != store.StateReverted && d.State != store.StateCancelled {
return fmt.Errorf("relink: download %d is in state %s (expected reverted)", id, d.State) return fmt.Errorf("relink: download %d is in state %s (expected reverted/cancelled)", id, d.State)
} }
if !d.Infohash.Valid { if !d.Infohash.Valid {
return fmt.Errorf("relink: download %d has no infohash", id) return fmt.Errorf("relink: download %d has no infohash", id)
@@ -325,7 +325,23 @@ func (w *Worker) Relink(ctx context.Context, id int64) error {
return fmt.Errorf("relink: %w", err) return fmt.Errorf("relink: %w", err)
} }
w.transition(ctx, *d, store.StateRecognizing, "", "") w.transition(ctx, *d, store.StateRecognizing, "", "")
w.log.Info("relink: re-recognizing reverted download", "download_id", id) w.log.Info("relink: re-recognizing download", "download_id", id, "from", d.State)
return nil
}
// Rerecognize перезапускает распознавание для задачи в review/deferred без
// добавления подсказки: контекст и прежние подсказки уже накоплены. Поллинг-
// цикл проведёт задачу recognizing → review заново.
func (w *Worker) Rerecognize(ctx context.Context, id int64) error {
w.mu.Lock()
defer w.mu.Unlock()
d, err := w.requireReviewable(ctx, id, "rerecognize")
if err != nil {
return err
}
w.log.Info("review: re-recognizing without hint", "download_id", id)
w.transition(ctx, *d, store.StateRecognizing, "", "")
return nil return nil
} }
+47 -3
View File
@@ -120,14 +120,58 @@ func TestRelink_RevertedToRecognizing(t *testing.T) {
} }
} }
func TestRelink_RejectsNonReverted(t *testing.T) { func TestRelink_CancelledToRecognizing(t *testing.T) {
st := newMemStore() st := newMemStore()
st.put(completedDownload(1)) // не reverted d := revertedDownload(1)
d.State = store.StateCancelled
st.put(d)
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}}}
w := testWorkerWith(st, qb, &fakeRecognizer{result: seriesResult()}, nil)
if err := w.Relink(context.Background(), 1); err != nil {
t.Fatalf("Relink: %v", err)
}
if st.downloads[1].State != store.StateRecognizing {
t.Fatalf("state = %q, want recognizing", st.downloads[1].State)
}
if st.overrides[1][ovrForceReview] != "1" {
t.Errorf("force_review override = %q, want 1", st.overrides[1][ovrForceReview])
}
}
func TestRelink_RejectsActiveState(t *testing.T) {
st := newMemStore()
st.put(completedDownload(1)) // не reverted/cancelled
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest}}} qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest}}}
w := testWorkerWith(st, qb, &fakeRecognizer{}, nil) w := testWorkerWith(st, qb, &fakeRecognizer{}, nil)
if err := w.Relink(context.Background(), 1); err == nil { if err := w.Relink(context.Background(), 1); err == nil {
t.Fatal("ожидали ошибку для не-reverted задачи, получили nil") t.Fatal("ожидали ошибку для не-reverted/cancelled задачи, получили nil")
}
}
func TestRerecognize_ReviewToRecognizing(t *testing.T) {
st := newMemStore()
d := completedDownload(1)
d.State = store.StateReview
st.put(d)
w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)
if err := w.Rerecognize(context.Background(), 1); err != nil {
t.Fatalf("Rerecognize: %v", err)
}
if st.downloads[1].State != store.StateRecognizing {
t.Fatalf("state = %q, want recognizing", st.downloads[1].State)
}
}
func TestRerecognize_RejectsNonReview(t *testing.T) {
st := newMemStore()
st.put(completedDownload(1)) // completed, не review/deferred
w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)
if err := w.Rerecognize(context.Background(), 1); err == nil {
t.Fatal("ожидали ошибку для не-review задачи, получили nil")
} }
} }
+4
View File
@@ -150,6 +150,10 @@
<p><textarea name="hint" rows="2" placeholder="подсказка для распознавания, напр. «это второй сезон, рус+англ дорожки»"></textarea></p> <p><textarea name="hint" rows="2" placeholder="подсказка для распознавания, напр. «это второй сезон, рус+англ дорожки»"></textarea></p>
<button type="submit">🔁 Уточнить</button> <button type="submit">🔁 Уточнить</button>
</form> </form>
<form method="post" action="/ui/downloads/{{.ID}}/rerecognize" style="margin-top:.4rem">
<button type="submit">🔄 Распознать заново</button>
<small>(без новой подсказки — по уже накопленному контексту)</small>
</form>
{{if .Hints}} {{if .Hints}}
<p><small>Подсказки: {{range $i, $h := .Hints}}{{if $i}} · {{end}}«{{$h}}»{{end}}</small></p> <p><small>Подсказки: {{range $i, $h := .Hints}}{{if $i}} · {{end}}«{{$h}}»{{end}}</small></p>
{{end}} {{end}}